From 9d37d7155ac3b42ad8ec227d1a25d4b5aa523e0a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:50:01 +0800 Subject: [PATCH 001/248] test(docs): require package subsystem ownership --- ...package-anchored-subsystem-pages.i18n.yaml | 4 +- ...-08-03-package-anchored-subsystem-pages.md | 5 +- ...-03-package-anchored-subsystem-pages.zh.md | 5 +- package.json | 1 + packages/typert/README.i18n.yaml | 4 +- packages/typert/README.md | 2 + packages/typert/README.zh.md | 2 + scripts/run-gates.spec.ts | 6 + scripts/run-gates.ts | 1 + scripts/verify-subsystem-pages.spec.ts | 95 ++++++++++++ scripts/verify-subsystem-pages.ts | 145 ++++++++++++++++++ 11 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 scripts/verify-subsystem-pages.spec.ts create mode 100644 scripts/verify-subsystem-pages.ts diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index b124dbfd9e..6bfa2c83dc 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.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 .agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: f429f3d41c1f152e83faeb12c379d221627e767f -2026-08-03-package-anchored-subsystem-pages.zh.md: 3a56fa39357591197606a28b49cf0eac8f58963e +2026-08-03-package-anchored-subsystem-pages.md: 7326d1cc9432555b07a3b5a047fdddb2cbb6d1d2 +2026-08-03-package-anchored-subsystem-pages.zh.md: ba771c586cc121be69f8e864a8ed632059f2b668 diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index f429f3d41c..7326d1cc94 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -14,7 +14,9 @@ Every `docs/subsystems/` page anchors to the package or package group that decla Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links). -Every `packages//README.md` pair is a thin front door in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. +Every `packages//README.md` pair is a thin front door in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. A group that declares no standalone subsystem reference is instead classified with a non-empty rationale in `GROUPS_WITHOUT_SUBSYSTEM_PAGE`; load-bearing prose that outgrows the group README relocates to an owning subsystems page rather than being deleted. + +`verify-subsystem-pages` discovers groups from both group READMEs and child package manifests. It rejects a missing group README, a group with neither a direct subsystem-page link nor an explicit exemption, a blank or orphaned exemption, an exempt group that gains a link, and a link whose page is absent. The gate runs as an independent `doc-sync` leaf, so adding a package group cannot silently omit its documentation owner. The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so a page added by a later PR (or absorbed in a merge) cannot silently miss the index. @@ -29,6 +31,7 @@ The [subsystems README](../../../../docs/subsystems/README.md) indexes every pag ## Consequences - Which page documents a type is predictable from `packages//`; the subsystems README is a complete index enforced by test. +- Every package group makes its subsystem owner or justified absence reviewable, and the repository gate rejects unclassified additions and stale exemptions. - Generated signature footers link the agent ownership vocabulary instead of silently exempting it. - `verify-type-equiv`'s 1:1 manifest keeps each paste single-homed; the duplicate `SessionEvent` paste is gone. - The [original catalog note](2026-06-20-core-data-structures-catalog.md) remains the owner of the `ts type-equiv` drift-gate mechanism; only its page-scoping rule is superseded here. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index 3a56fa3935..ba771c586c 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -14,7 +14,9 @@ Status: implemented 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给真正服务本地或 vendored 的形状。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.md);core.md 概括并链接)。 -每个 `packages//README.md` 配对都是统一形状的轻薄门面:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 +每个 `packages//README.md` 配对都是统一形状的轻薄门面:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。未声明独立子系统参考资料的分组,改为在 `GROUPS_WITHOUT_SUBSYSTEM_PAGE` 中以非空理由分类;超出分组 README 体量的承重散文迁移到拥有方子系统页面,而非删除。 + +`verify-subsystem-pages` 同时从分组 README 和子包 manifest(元数据清单)发现分组。它会拒绝缺少分组 README、分组既没有直接子系统页面链接也没有显式豁免、豁免为空或成为孤立项、已豁免分组新增链接,以及链接指向的页面不存在。该门禁作为独立的 `doc-sync`(文档同步门禁)叶节点运行,因此新增包分组时不能悄悄遗漏其文档拥有方。 [子系统 README](../../../../docs/subsystems/README.md) 在两个语言侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每页一行表格,因此后续 PR 新增(或合并吸收)的页面无法悄悄缺席索引。 @@ -29,6 +31,7 @@ Status: implemented ## Consequences - 哪一页记录某类型可由 `packages//` 预测;子系统 README 是由测试强制的完整索引。 +- 每个包分组都会将其子系统拥有方或合理的缺席原因暴露给评审,且仓库门禁会拒绝未分类的新增项和陈旧豁免。 - 生成的签名页脚链接 agent 所有权词汇,而不是静默豁免。 - `verify-type-equiv` 的 1:1 manifest 保证每个粘贴单一归属;重复的 `SessionEvent` 粘贴已移除。 - [原目录 note](2026-06-20-core-data-structures-catalog.md) 仍拥有 `ts type-equiv` 漂移检查机制;此处仅取代其页面范围界定规则。 diff --git a/package.json b/package.json index d417354739..aa9a742204 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-subsystem-pages": "tsx scripts/verify-subsystem-pages.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml index 3a061e43fd..451db06529 100644 --- a/packages/typert/README.i18n.yaml +++ b/packages/typert/README.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 packages/typert/README.md -README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160 -README.zh.md: aae74bf89f657f50f4519b2f8a0628fd391f8de3 +README.md: 36b97510ceb6524b396e53eb05b6d0e702fce9fe +README.zh.md: 0dc82bf69d58af3d4ccd51c37b546da414af8fcf diff --git a/packages/typert/README.md b/packages/typert/README.md index ad9f843e48..36b97510ce 100644 --- a/packages/typert/README.md +++ b/packages/typert/README.md @@ -9,3 +9,5 @@ Typert separates source analysis, runtime storage, and Loader discovery. | [`registry/`](registry/README.md) | Stores runtime package reflection and schemas | `ctx.typert` | | [`loader/`](loader/README.md) | Discovers Loader entries and registers generated host artifacts | consumes `ctx.loader` and `ctx.typert` | | [`generator/`](generator/README.md) | Generates runtime artifacts from source types | build-time library | + +See [TypeRT remote calls](../../docs/subsystems/typert.md) for the generated invocation, schema, and transport contracts. diff --git a/packages/typert/README.zh.md b/packages/typert/README.zh.md index aae74bf89f..0dc82bf69d 100644 --- a/packages/typert/README.zh.md +++ b/packages/typert/README.zh.md @@ -9,3 +9,5 @@ Typert 将源代码分析、运行时存储和 Loader 发现机制分离。 | [`registry/`](registry/README.md) | 存储运行时包反射和 schema | `ctx.typert` | | [`loader/`](loader/README.md) | 发现 Loader 条目并注册生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` | | [`generator/`](generator/README.md) | 从源代码类型生成运行时产物 | 构建时库 | + +有关所生成的调用、schema 和传输三方面的约定,参见 [TypeRT 远程调用](../../docs/subsystems/typert.md)。 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 5c4ba9899a..83504e5e3e 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -83,6 +83,12 @@ describe('gate graph validation', () => { expect(ids).toContain('public-repository-links') }) + it('keeps package-group subsystem ownership in the documentation gate', () => { + const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id)) + + expect(ids).toContain('subsystem-pages') + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3bd0e11987..9d5545605c 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -590,6 +590,7 @@ function docSyncLeafGates(options: { pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), + pnpmScript('subsystem-pages', 'verify-subsystem-pages', { label: 'subsystem pages' }), pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }), pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }), pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }), diff --git a/scripts/verify-subsystem-pages.spec.ts b/scripts/verify-subsystem-pages.spec.ts new file mode 100644 index 0000000000..e7dbc87fc2 --- /dev/null +++ b/scripts/verify-subsystem-pages.spec.ts @@ -0,0 +1,95 @@ +/** Regression coverage for package-group subsystem-page ownership. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { auditSubsystemPages } from './verify-subsystem-pages.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-subsystem-pages-')) + roots.push(root) + return root +} + +function write(root: string, path: string, source: string): void { + const absolute = join(root, path) + mkdirSync(dirname(absolute), { recursive: true }) + writeFileSync(absolute, source) +} + +describe('package-group subsystem pages', () => { + it('accepts a direct page link and a justified no-page group', () => { + const root = fixture() + write(root, 'packages/alpha/README.md', '[types](../../docs/subsystems/alpha.md)\n') + write(root, 'packages/alpha/alpha/package.json', '{}\n') + write(root, 'docs/subsystems/alpha.md', '# Alpha\n') + write(root, 'packages/adapter/README.md', '# Adapter\n') + + expect(auditSubsystemPages(root, { adapter: 'Adapter over an existing subsystem.' })).toEqual({ + groups: 2, + linked: 1, + exempt: 1, + violations: [], + }) + }) + + it('rejects a new group whose README never declares subsystem ownership', () => { + const root = fixture() + write(root, 'packages/schedule/README.md', '# Schedule\n') + write(root, 'packages/schedule/tool-schedule/package.json', '{}\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/schedule/README.md: no direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('does not treat the subsystem index or a Chinese counterpart as an owning page', () => { + const root = fixture() + write( + root, + 'packages/wrong/README.md', + '[index](../../docs/subsystems/README.md) [Chinese](../../docs/subsystems/wrong.zh.md)\n', + ) + write(root, 'docs/subsystems/README.md', '# Subsystems\n') + write(root, 'docs/subsystems/wrong.zh.md', '# Wrong\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/wrong/README.md: no direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('rejects missing group READMEs and missing linked pages', () => { + const root = fixture() + write(root, 'packages/no-readme/pkg/package.json', '{}\n') + write(root, 'packages/broken/README.md', '[missing](../../docs/subsystems/missing.md)\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/broken/README.md: linked subsystem page does not exist: docs/subsystems/missing.md', + 'packages/no-readme/README.md: package group has no group README declaring subsystem ownership', + ]) + }) + + it('rejects blank, orphaned, and stale exemptions', () => { + const root = fixture() + write(root, 'packages/linked/README.md', '[types](../../docs/subsystems/linked.md)\n') + write(root, 'docs/subsystems/linked.md', '# Linked\n') + write(root, 'packages/blank/README.md', '# Blank\n') + + expect(auditSubsystemPages(root, { + blank: ' ', + linked: 'No page.', + orphan: 'Removed group.', + }).violations).toEqual([ + 'exemption blank: missing justification for omitting a subsystem page', + 'exemption orphan: no matching package group; remove the stale entry', + 'packages/linked/README.md: links a subsystem page but remains exempt; remove the stale exemption', + ]) + }) +}) diff --git a/scripts/verify-subsystem-pages.ts b/scripts/verify-subsystem-pages.ts new file mode 100644 index 0000000000..403a3c5035 --- /dev/null +++ b/scripts/verify-subsystem-pages.ts @@ -0,0 +1,145 @@ +/** + * Doc-sync gate for package-group subsystem references. Every package group + * either links at least one existing `docs/subsystems/` page from its English + * group README or carries an explicit, justified exemption below. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' + +const root = resolve(import.meta.dirname, '..') + +/** + * Package groups that do not own a standalone subsystem reference. Reasons + * are reviewable policy: a new group cannot silently inherit an exemption. + */ +export const GROUPS_WITHOUT_SUBSYSTEM_PAGE: Readonly> = { + acp: 'Protocol transport front door; the server package README owns its interoperability contract.', + api: 'Remote transport and BFF assembly; Typert and the package READMEs own the underlying contracts.', + boot: 'Shared application-bin boot library rather than a runtime subsystem.', + bundle: 'Composition patch carriers whose mounted packages own all runtime contracts.', + e2b: 'Provider implementations of the filesystem and subprocess subsystems, not a new capability contract.', + examples: 'Non-product demonstration compositions whose mounted packages own all runtime contracts.', + experimental: 'Empty staging group; promoted packages move to their product-role group before release.', + feedback: 'One command producer and inline log-event payload; its package README and persistence catalog own the complete contract.', + hooks: 'External hook-protocol bridges over existing interception points, not a new Harness service.', + mcp: 'Integration adapter that contributes external tools through the existing tool registry.', + scaffold: 'Developer tooling and out-of-process SDK transport rather than an in-process Harness subsystem.', + 'self-modification': 'Model-facing consumers of the existing tool and Cordis runtime contracts.', + util: 'Low-level primitives whose business semantics remain with their consuming subsystems.', +} + +/** Result of auditing package-group subsystem documentation. */ +export interface SubsystemPageAudit { + /** Package groups discovered from group READMEs or child package manifests. */ + readonly groups: number + /** Groups carrying at least one direct subsystem-page link. */ + readonly linked: number + /** Groups covered by an explicit no-page policy. */ + readonly exempt: number + /** Actionable contract violations. */ + readonly violations: readonly string[] +} + +/** Normalize one filesystem glob result to repository slash form. */ +function normalize(path: string): string { + return path.split(sep).join('/') +} + +/** Extract the package-group segment from a repository-relative path. */ +function groupOf(path: string): string { + const group = path.split('/')[1] + if (group === undefined || group.length === 0) throw new Error(`invalid package path: ${path}`) + return group +} + +/** Return canonical subsystem-page targets linked by one group README. */ +function subsystemLinks(source: string): string[] { + const links = new Set() + const pattern = /\]\(\.\.\/\.\.\/docs\/subsystems\/([^\s)#]+\.md)(?:#[^)]+)?\)/g + for (const match of source.matchAll(pattern)) { + const page = match[1] + if (page !== undefined && page !== 'README.md' && !page.endsWith('.zh.md')) { + links.add(`docs/subsystems/${page}`) + } + } + return [...links].sort() +} + +/** + * Audit package-group subsystem ownership for one repository tree. + * @param scanRoot - repository root containing `packages/` and `docs/`. + * @param exemptions - groups intentionally carrying no subsystem-page link. + * @returns counts plus every actionable violation. + */ +export function auditSubsystemPages( + scanRoot: string = root, + exemptions: Readonly> = GROUPS_WITHOUT_SUBSYSTEM_PAGE, +): SubsystemPageAudit { + const readmes = globSync('packages/*/README.md', { cwd: scanRoot }).map(normalize).sort() + const manifests = globSync('packages/*/*/package.json', { cwd: scanRoot }).map(normalize).sort() + const groups = new Set([...readmes, ...manifests].map(groupOf)) + const violations: string[] = [] + let linked = 0 + let exempt = 0 + + for (const [group, reason] of Object.entries(exemptions)) { + if (!groups.has(group)) { + violations.push(`exemption ${group}: no matching package group; remove the stale entry`) + } + if (reason.trim().length === 0) { + violations.push(`exemption ${group}: missing justification for omitting a subsystem page`) + } + } + + for (const group of [...groups].sort()) { + const readme = `packages/${group}/README.md` + const readmePath = resolve(scanRoot, readme) + if (!existsSync(readmePath)) { + violations.push(`${readme}: package group has no group README declaring subsystem ownership`) + continue + } + + const links = subsystemLinks(readFileSync(readmePath, 'utf8')) + const isExempt = Object.hasOwn(exemptions, group) + if (links.length === 0) { + if (isExempt) { + exempt += 1 + } else { + violations.push( + `${readme}: no direct docs/subsystems/*.md link; add the owning page and link,` + + ' or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ) + } + continue + } + + linked += 1 + if (isExempt) { + violations.push(`${readme}: links a subsystem page but remains exempt; remove the stale exemption`) + } + for (const page of links) { + if (!existsSync(resolve(scanRoot, page))) { + violations.push(`${readme}: linked subsystem page does not exist: ${page}`) + } + } + } + + return { groups: groups.size, linked, exempt, violations } +} + +/** Run the repository audit as a standalone doc-sync gate. */ +function main(): void { + const audit = auditSubsystemPages() + if (audit.violations.length > 0) { + console.error('verify-subsystem-pages: package-group documentation violations found:') + for (const violation of audit.violations) console.error(` ${violation}`) + process.exit(1) + } + console.log( + `verify-subsystem-pages: ${String(audit.groups)} group(s) checked` + + ` (${String(audit.linked)} linked, ${String(audit.exempt)} explicitly exempt), all conform.`, + ) +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) main() From 4125dac22dabf0453d861a91e39974b6458b9844 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:30:03 +0800 Subject: [PATCH 002/248] fix(docs): harden subsystem ownership links --- ...package-anchored-subsystem-pages.i18n.yaml | 4 +- ...-08-03-package-anchored-subsystem-pages.md | 2 +- ...-03-package-anchored-subsystem-pages.zh.md | 2 +- packages/AGENTS.md | 2 +- scripts/verify-subsystem-pages.spec.ts | 38 +++++++++++++++++-- scripts/verify-subsystem-pages.ts | 16 ++++---- 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index 6bfa2c83dc..6c7548e4ce 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.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 .agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: 7326d1cc9432555b07a3b5a047fdddb2cbb6d1d2 -2026-08-03-package-anchored-subsystem-pages.zh.md: ba771c586cc121be69f8e864a8ed632059f2b668 +2026-08-03-package-anchored-subsystem-pages.md: e5ec7f561a4f4725c2662415f23144c0182dce80 +2026-08-03-package-anchored-subsystem-pages.zh.md: 409d0f62e026275787f02ab781153739108a107f diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index 7326d1cc94..e5ec7f561a 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -16,7 +16,7 @@ Every type a generated signature references must resolve somewhere in the folder Every `packages//README.md` pair is a thin front door in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. A group that declares no standalone subsystem reference is instead classified with a non-empty rationale in `GROUPS_WITHOUT_SUBSYSTEM_PAGE`; load-bearing prose that outgrows the group README relocates to an owning subsystems page rather than being deleted. -`verify-subsystem-pages` discovers groups from both group READMEs and child package manifests. It rejects a missing group README, a group with neither a direct subsystem-page link nor an explicit exemption, a blank or orphaned exemption, an exempt group that gains a link, and a link whose page is absent. The gate runs as an independent `doc-sync` leaf, so adding a package group cannot silently omit its documentation owner. +`verify-subsystem-pages` discovers groups from both group READMEs and child package manifests. It rejects a missing group README, a group with neither a reader-visible direct link to one English file under `docs/subsystems/` nor an explicit exemption, a blank or orphaned exemption, an exempt group that gains a link, and a link whose page is absent; code, comments, images, nested paths, and traversal do not satisfy ownership. The gate runs as an independent `doc-sync` leaf, so adding a package group cannot silently omit its documentation owner. The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so a page added by a later PR (or absorbed in a merge) cannot silently miss the index. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index ba771c586c..409d0f62e0 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -16,7 +16,7 @@ Status: implemented 每个 `packages//README.md` 配对都是统一形状的轻薄门面:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。未声明独立子系统参考资料的分组,改为在 `GROUPS_WITHOUT_SUBSYSTEM_PAGE` 中以非空理由分类;超出分组 README 体量的承重散文迁移到拥有方子系统页面,而非删除。 -`verify-subsystem-pages` 同时从分组 README 和子包 manifest(元数据清单)发现分组。它会拒绝缺少分组 README、分组既没有直接子系统页面链接也没有显式豁免、豁免为空或成为孤立项、已豁免分组新增链接,以及链接指向的页面不存在。该门禁作为独立的 `doc-sync`(文档同步门禁)叶节点运行,因此新增包分组时不能悄悄遗漏其文档拥有方。 +`verify-subsystem-pages` 同时从分组 README 和子包 manifest(元数据清单)发现分组。它会拒绝缺少分组 README、分组既没有面向读者且直接指向 `docs/subsystems/` 下某一个英文文件的链接也没有显式豁免、豁免为空或成为孤立项、已豁免分组新增链接,以及链接指向的页面不存在;代码、注释、图片、嵌套路径和路径穿越都不能满足所有权声明。该门禁作为独立的 `doc-sync`(文档同步门禁)叶节点运行,因此新增包分组时不能悄悄遗漏其文档拥有方。 [子系统 README](../../../../docs/subsystems/README.md) 在两个语言侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每页一行表格,因此后续 PR 新增(或合并吸收)的页面无法悄悄缺席索引。 diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 6cff1b6327..98aac095df 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -22,6 +22,6 @@ Naming notes: - **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. -- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. +- Update package README and JSDoc contracts in the same commit as behavior, and verify them against code with [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md). Group READMEs declare subsystem ownership through a canonical English page link or justified [exemption](../scripts/verify-subsystem-pages.ts). - Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). - Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or Agent Note. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md)). diff --git a/scripts/verify-subsystem-pages.spec.ts b/scripts/verify-subsystem-pages.spec.ts index e7dbc87fc2..fa8fbc8553 100644 --- a/scripts/verify-subsystem-pages.spec.ts +++ b/scripts/verify-subsystem-pages.spec.ts @@ -27,7 +27,7 @@ function write(root: string, path: string, source: string): void { describe('package-group subsystem pages', () => { it('accepts a direct page link and a justified no-page group', () => { const root = fixture() - write(root, 'packages/alpha/README.md', '[types](../../docs/subsystems/alpha.md)\n') + write(root, 'packages/alpha/README.md', '[types](../../docs/subsystems/alpha.md#contract)\n') write(root, 'packages/alpha/alpha/package.json', '{}\n') write(root, 'docs/subsystems/alpha.md', '# Alpha\n') write(root, 'packages/adapter/README.md', '# Adapter\n') @@ -46,7 +46,7 @@ describe('package-group subsystem pages', () => { write(root, 'packages/schedule/tool-schedule/package.json', '{}\n') expect(auditSubsystemPages(root, {}).violations).toEqual([ - 'packages/schedule/README.md: no direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + 'packages/schedule/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', ]) }) @@ -61,7 +61,39 @@ describe('package-group subsystem pages', () => { write(root, 'docs/subsystems/wrong.zh.md', '# Wrong\n') expect(auditSubsystemPages(root, {}).violations).toEqual([ - 'packages/wrong/README.md: no direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + 'packages/wrong/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('does not count links hidden in code, comments, or image syntax', () => { + const root = fixture() + write( + root, + 'packages/hidden/README.md', + [ + '`[inline](../../docs/subsystems/hidden.md)`', + '```md', + '[fenced](../../docs/subsystems/hidden.md)', + '```', + '', + '![image](../../docs/subsystems/hidden.md)', + '', + ].join('\n'), + ) + write(root, 'docs/subsystems/hidden.md', '# Hidden\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/hidden/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('rejects a link that escapes the subsystem directory', () => { + const root = fixture() + write(root, 'packages/escape/README.md', '[escape](../../docs/subsystems/../architecture.md)\n') + write(root, 'docs/architecture.md', '# Architecture\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/escape/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', ]) }) diff --git a/scripts/verify-subsystem-pages.ts b/scripts/verify-subsystem-pages.ts index 403a3c5035..6401cfdae8 100644 --- a/scripts/verify-subsystem-pages.ts +++ b/scripts/verify-subsystem-pages.ts @@ -6,6 +6,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { resolve, sep } from 'node:path' +import { parseMarkdown, visitMarkdown } from './markdown.ts' const root = resolve(import.meta.dirname, '..') @@ -56,13 +57,12 @@ function groupOf(path: string): string { /** Return canonical subsystem-page targets linked by one group README. */ function subsystemLinks(source: string): string[] { const links = new Set() - const pattern = /\]\(\.\.\/\.\.\/docs\/subsystems\/([^\s)#]+\.md)(?:#[^)]+)?\)/g - for (const match of source.matchAll(pattern)) { - const page = match[1] - if (page !== undefined && page !== 'README.md' && !page.endsWith('.zh.md')) { - links.add(`docs/subsystems/${page}`) - } - } + visitMarkdown(parseMarkdown(source), (node) => { + if (node.type !== 'link') return + const match = /^\.\.\/\.\.\/docs\/subsystems\/([^/#?]+\.md)(?:#[^?#]*)?$/.exec(node.url) + const page = match?.[1] + if (page !== undefined && page !== 'README.md' && !page.endsWith('.zh.md')) links.add(`docs/subsystems/${page}`) + }) return [...links].sort() } @@ -107,7 +107,7 @@ export function auditSubsystemPages( exempt += 1 } else { violations.push( - `${readme}: no direct docs/subsystems/*.md link; add the owning page and link,` + `${readme}: no reader-visible direct docs/subsystems/*.md link; add the owning page and link,` + ' or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', ) } From 16d86cfba7d77adc9056948c0eae8bce74d10c11 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:07:57 +0800 Subject: [PATCH 003/248] test: decouple the translation prompt snapshot from live documents The snapshot embedded five live bilingual document pairs as reviewed examples, so editing any of them (README, development guide, i18n docs) churned the snapshot. Replace them with three synthetic fixture pairs (product, rules, agent-note shapes) under scripts/fixtures; the prompt examples stay representative without tracking real document content. --- .../translation-prompt/examples/agent-note.md | 17 +++++++++++ .../examples/agent-note.zh.md | 17 +++++++++++ .../translation-prompt/examples/product.md | 22 +++++++++++++++ .../translation-prompt/examples/product.zh.md | 22 +++++++++++++++ .../translation-prompt/examples/rules.md | 15 ++++++++++ .../translation-prompt/examples/rules.zh.md | 15 ++++++++++ .../request-response.expected.json | 28 ++++--------------- scripts/verify-translation-prompt.ts | 13 +++++---- 8 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 scripts/fixtures/translation-prompt/examples/agent-note.md create mode 100644 scripts/fixtures/translation-prompt/examples/agent-note.zh.md create mode 100644 scripts/fixtures/translation-prompt/examples/product.md create mode 100644 scripts/fixtures/translation-prompt/examples/product.zh.md create mode 100644 scripts/fixtures/translation-prompt/examples/rules.md create mode 100644 scripts/fixtures/translation-prompt/examples/rules.zh.md diff --git a/scripts/fixtures/translation-prompt/examples/agent-note.md b/scripts/fixtures/translation-prompt/examples/agent-note.md new file mode 100644 index 0000000000..6adafee070 --- /dev/null +++ b/scripts/fixtures/translation-prompt/examples/agent-note.md @@ -0,0 +1,17 @@ +# Agent Note: Offline-first defaults + +Status: implemented + +English | [中文](agent-note.zh.md) + +## Problem + +Online checks delayed every run. + +## Decision + +Run offline by default; expose one opt-in flag. + +## Consequences + +Runs start instantly. Telemetry stays off unless enabled. diff --git a/scripts/fixtures/translation-prompt/examples/agent-note.zh.md b/scripts/fixtures/translation-prompt/examples/agent-note.zh.md new file mode 100644 index 0000000000..fdcca89436 --- /dev/null +++ b/scripts/fixtures/translation-prompt/examples/agent-note.zh.md @@ -0,0 +1,17 @@ +# Agent Note: 默认离线 + +Status: implemented + +[English](agent-note.md) | 中文 + +## 问题 + +每次运行都被在线检查拖慢。 + +## 决策 + +默认离线运行;提供一个选择加入的开关。 + +## 后果 + +运行即刻启动;遥测保持关闭,除非显式启用。 diff --git a/scripts/fixtures/translation-prompt/examples/product.md b/scripts/fixtures/translation-prompt/examples/product.md new file mode 100644 index 0000000000..21893ec05f --- /dev/null +++ b/scripts/fixtures/translation-prompt/examples/product.md @@ -0,0 +1,22 @@ +# Acme Agent + +English | [中文](product.zh.md) + +Acme Agent is an open-source agent harness that automates repository chores. + +It runs fully offline. **No telemetry is transmitted.** + +## Install + +Install Node.js 24, then run: + +```sh +npx acme-agent setup +``` + +The command prints the setup URL, which is `http://127.0.0.1:3080` by default. + +## Community + +- Report bugs in the issue tracker. +- Add the `acme-agent` topic to your plugin repository. diff --git a/scripts/fixtures/translation-prompt/examples/product.zh.md b/scripts/fixtures/translation-prompt/examples/product.zh.md new file mode 100644 index 0000000000..a5df542fe7 --- /dev/null +++ b/scripts/fixtures/translation-prompt/examples/product.zh.md @@ -0,0 +1,22 @@ +# Acme Agent + +[English](product.md) | 中文 + +Acme Agent 是一款开源 agent harness(智能体框架),用于自动化仓库日常事务。 + +它完全离线运行。**不会传输任何遥测数据。** + +## 安装 + +安装 Node.js 24,然后运行: + +```sh +npx acme-agent setup +``` + +该命令会打印设置地址,默认地址为 `http://127.0.0.1:3080`。 + +## 社区 + +- 在 Issue 跟踪器中报告 bug。 +- 为你的插件仓库添加 `acme-agent` 主题。 diff --git a/scripts/fixtures/translation-prompt/examples/rules.md b/scripts/fixtures/translation-prompt/examples/rules.md new file mode 100644 index 0000000000..359856280c --- /dev/null +++ b/scripts/fixtures/translation-prompt/examples/rules.md @@ -0,0 +1,15 @@ +# Pairing rules + +English | [中文](rules.zh.md) + +These rules govern the Chinese counterpart of every documentation pair. + +## Priority levels + +| Level | Meaning | +|---|---| +| MUST | The pairing gate rejects a non-conforming pair. | +| SHOULD | Deviate only with a stated reason. | + +- Preserve every proposition of the source. +- Keep code spans verbatim. diff --git a/scripts/fixtures/translation-prompt/examples/rules.zh.md b/scripts/fixtures/translation-prompt/examples/rules.zh.md new file mode 100644 index 0000000000..0c1d869c6e --- /dev/null +++ b/scripts/fixtures/translation-prompt/examples/rules.zh.md @@ -0,0 +1,15 @@ +# 配对规则 + +[English](rules.md) | 中文 + +这些规则约束每个文档配对的中文对侧。 + +## 优先级 + +| 级别 | 含义 | +|---|---| +| MUST | 配对门禁会拒绝不符合要求的配对。 | +| SHOULD | 仅在说明理由后偏离。 | + +- 保留源文的每个命题。 +- 代码片段原样保留。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 2d874452ae..3f4a99d17c 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,43 +8,27 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).\n\nIt uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis).\n\n## Developer preview\n\nDeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.**\n\n## Run\n\n### Run from `npm`\n\nInstall `Node.js`, then run:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command starts the Web UI, served at `http://127.0.0.1:3080` by default. See [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run from a repository checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## Community and support\n\n- Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).\n- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Development\n\nStart with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# Acme Agent\n\nEnglish | [中文](product.zh.md)\n\nAcme Agent is an open-source agent harness that automates repository chores.\n\nIt runs fully offline. **No telemetry is transmitted.**\n\n## Install\n\nInstall Node.js 24, then run:\n\n```sh\nnpx acme-agent setup\n```\n\nThe command prints the setup URL, which is `http://127.0.0.1:3080` by default.\n\n## Community\n\n- Report bugs in the issue tracker.\n- Add the `acme-agent` topic to your plugin repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 主题,便于被发现。\n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.md)与[架构文档](docs/architecture.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" + "content": "# Acme Agent\n\n[English](product.md) | 中文\n\nAcme Agent 是一款开源 agent harness(智能体框架),用于自动化仓库日常事务。\n\n它完全离线运行。**不会传输任何遥测数据。**\n\n## 安装\n\n安装 Node.js 24,然后运行:\n\n```sh\nnpx acme-agent setup\n```\n\n该命令会打印设置地址,默认地址为 `http://127.0.0.1:3080`。\n\n## 社区\n\n- 在 Issue 跟踪器中报告 bug。\n- 为你的插件仓库添加 `acme-agent` 主题。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host Typert graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated Typert contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nRun the repository build separately before using these source-checkout demos:\n\n```sh\npnpm run build\n```\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Pairing rules\n\nEnglish | [中文](rules.zh.md)\n\nThese rules govern the Chinese counterpart of every documentation pair.\n\n## Priority levels\n\n| Level | Meaning |\n|---|---|\n| MUST | The pairing gate rejects a non-conforming pair. |\n| SHOULD | Deviate only with a stated reason. |\n\n- Preserve every proposition of the source.\n- Keep code spans verbatim.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出目录通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通包只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host 包、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` 包及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新包只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host Typert 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务服务在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验包入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 Typert 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;包公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n从源码 checkout 运行这些演示前,请单独执行仓库构建:\n\n```sh\npnpm run build\n```\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型定义(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 配对规则\n\n[English](rules.md) | 中文\n\n这些规则约束每个文档配对的中文对侧。\n\n## 优先级\n\n| 级别 | 含义 |\n|---|---|\n| MUST | 配对门禁会拒绝不符合要求的配对。 |\n| SHOULD | 仅在说明理由后偏离。 |\n\n- 保留源文的每个命题。\n- 代码片段原样保留。\n" }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. A README published outside GitHub, such as PyPI project metadata, may use the canonical `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL to the same counterpart so the switcher still resolves there.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Agent Note: Offline-first defaults\n\nStatus: implemented\n\nEnglish | [中文](agent-note.zh.md)\n\n## Problem\n\nOnline checks delayed every run.\n\n## Decision\n\nRun offline by default; expose one opt-in flag.\n\n## Consequences\n\nRuns start instantly. Telemetry stays off unless enabled.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 Git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的 worktree 内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 YAML diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。发布到 GitHub 以外位置的 README(例如 PyPI 项目元数据)可以改用指向同一对侧文件的规范 `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL,使切换行在该位置仍可访问。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest(元数据清单)中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" - }, - { - "role": "user", - "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. A README rendered outside GitHub MAY use the canonical public repository URL to its exact counterpart as documented in [README.md](README.md). Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" - }, - { - "role": "assistant", - "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。在 GitHub 以外位置渲染的 README 可以按 [README.md](README.md) 的规定,使用指向确切对侧文件的规范公开仓库 URL。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" - }, - { - "role": "user", - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" - }, - { - "role": "assistant", - "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁能够发现。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync(文档同步门禁)强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 Git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 YAML diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两侧的 hash 均与记录匹配、中文侧和所有人工撰写的英文源都带语言切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、语言切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和所消费的响应。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest(元数据清单)通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" + "content": "# Agent Note: 默认离线\n\nStatus: implemented\n\n[English](agent-note.md) | 中文\n\n## 问题\n\n每次运行都被在线检查拖慢。\n\n## 决策\n\n默认离线运行;提供一个选择加入的开关。\n\n## 后果\n\n运行即刻启动;遥测保持关闭,除非显式启用。\n" }, { "role": "user", diff --git a/scripts/verify-translation-prompt.ts b/scripts/verify-translation-prompt.ts index 6ad1787a9d..5be641568e 100644 --- a/scripts/verify-translation-prompt.ts +++ b/scripts/verify-translation-prompt.ts @@ -24,14 +24,15 @@ try { if (mode !== undefined && mode !== '--snapshot') throw new Error(`unsupported argument ${JSON.stringify(mode)}`) const document = read('docs/i18n/translation-prompt.md') const terminology = read('docs/i18n/terminology.md') + // Synthetic reviewed examples, not live documents: editing a paired document must not + // churn the prompt snapshot. Each pair mirrors the other side's structure and uses + // terminology-table forms. const examplePaths = [ - ['README.md', 'README.zh.md'], - ['docs/development.md', 'docs/development.zh.md'], - ['docs/i18n/README.md', 'docs/i18n/README.zh.md'], - ['docs/i18n/translation-rules.md', 'docs/i18n/translation-rules.zh.md'], + ['scripts/fixtures/translation-prompt/examples/product.md', 'scripts/fixtures/translation-prompt/examples/product.zh.md'], + ['scripts/fixtures/translation-prompt/examples/rules.md', 'scripts/fixtures/translation-prompt/examples/rules.zh.md'], [ - '.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md', - '.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md', + 'scripts/fixtures/translation-prompt/examples/agent-note.md', + 'scripts/fixtures/translation-prompt/examples/agent-note.zh.md', ], ] as const const examples: TranslationExample[] = examplePaths.map(([english, chinese]) => ({ From 9d0ef6f5bb5887dc709faf83df53730de6d68b2a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:48:09 +0800 Subject: [PATCH 004/248] test: make the Agent Note translation fixture generic --- .../fixtures/translation-prompt/examples/agent-note.md | 8 ++++---- .../fixtures/translation-prompt/examples/agent-note.zh.md | 8 ++++---- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/fixtures/translation-prompt/examples/agent-note.md b/scripts/fixtures/translation-prompt/examples/agent-note.md index 6adafee070..a1b6e7f0d5 100644 --- a/scripts/fixtures/translation-prompt/examples/agent-note.md +++ b/scripts/fixtures/translation-prompt/examples/agent-note.md @@ -1,4 +1,4 @@ -# Agent Note: Offline-first defaults +# Agent Note: Consistent examples Status: implemented @@ -6,12 +6,12 @@ English | [中文](agent-note.zh.md) ## Problem -Online checks delayed every run. +Similar examples used different headings. ## Decision -Run offline by default; expose one opt-in flag. +Use the same headings for similar examples. ## Consequences -Runs start instantly. Telemetry stays off unless enabled. +Examples are easier to compare. diff --git a/scripts/fixtures/translation-prompt/examples/agent-note.zh.md b/scripts/fixtures/translation-prompt/examples/agent-note.zh.md index fdcca89436..c9f4c02246 100644 --- a/scripts/fixtures/translation-prompt/examples/agent-note.zh.md +++ b/scripts/fixtures/translation-prompt/examples/agent-note.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 默认离线 +# Agent Note: 一致的示例 Status: implemented @@ -6,12 +6,12 @@ Status: implemented ## 问题 -每次运行都被在线检查拖慢。 +相似示例使用了不同的标题。 ## 决策 -默认离线运行;提供一个选择加入的开关。 +相似示例使用相同的标题。 ## 后果 -运行即刻启动;遥测保持关闭,除非显式启用。 +示例更易于比较。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 3f4a99d17c..d6f91a28f2 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,11 +24,11 @@ }, { "role": "user", - "content": "# Agent Note: Offline-first defaults\n\nStatus: implemented\n\nEnglish | [中文](agent-note.zh.md)\n\n## Problem\n\nOnline checks delayed every run.\n\n## Decision\n\nRun offline by default; expose one opt-in flag.\n\n## Consequences\n\nRuns start instantly. Telemetry stays off unless enabled.\n" + "content": "# Agent Note: Consistent examples\n\nStatus: implemented\n\nEnglish | [中文](agent-note.zh.md)\n\n## Problem\n\nSimilar examples used different headings.\n\n## Decision\n\nUse the same headings for similar examples.\n\n## Consequences\n\nExamples are easier to compare.\n" }, { "role": "assistant", - "content": "# Agent Note: 默认离线\n\nStatus: implemented\n\n[English](agent-note.md) | 中文\n\n## 问题\n\n每次运行都被在线检查拖慢。\n\n## 决策\n\n默认离线运行;提供一个选择加入的开关。\n\n## 后果\n\n运行即刻启动;遥测保持关闭,除非显式启用。\n" + "content": "# Agent Note: 一致的示例\n\nStatus: implemented\n\n[English](agent-note.md) | 中文\n\n## 问题\n\n相似示例使用了不同的标题。\n\n## 决策\n\n相似示例使用相同的标题。\n\n## 后果\n\n示例更易于比较。\n" }, { "role": "user", From 36cca4028161571cfb291565fec51c5e327f48dc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:58:03 +0800 Subject: [PATCH 005/248] test: make the product translation fixture generic --- scripts/fixtures/translation-prompt/examples/product.md | 2 +- scripts/fixtures/translation-prompt/examples/product.zh.md | 2 +- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/fixtures/translation-prompt/examples/product.md b/scripts/fixtures/translation-prompt/examples/product.md index 21893ec05f..045d842a58 100644 --- a/scripts/fixtures/translation-prompt/examples/product.md +++ b/scripts/fixtures/translation-prompt/examples/product.md @@ -4,7 +4,7 @@ English | [中文](product.zh.md) Acme Agent is an open-source agent harness that automates repository chores. -It runs fully offline. **No telemetry is transmitted.** +This paragraph contains neutral placeholder text for the example. ## Install diff --git a/scripts/fixtures/translation-prompt/examples/product.zh.md b/scripts/fixtures/translation-prompt/examples/product.zh.md index a5df542fe7..3a831a6456 100644 --- a/scripts/fixtures/translation-prompt/examples/product.zh.md +++ b/scripts/fixtures/translation-prompt/examples/product.zh.md @@ -4,7 +4,7 @@ Acme Agent 是一款开源 agent harness(智能体框架),用于自动化仓库日常事务。 -它完全离线运行。**不会传输任何遥测数据。** +本段包含供示例使用的中性占位文本。 ## 安装 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d6f91a28f2..0eb71c992d 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# Acme Agent\n\nEnglish | [中文](product.zh.md)\n\nAcme Agent is an open-source agent harness that automates repository chores.\n\nIt runs fully offline. **No telemetry is transmitted.**\n\n## Install\n\nInstall Node.js 24, then run:\n\n```sh\nnpx acme-agent setup\n```\n\nThe command prints the setup URL, which is `http://127.0.0.1:3080` by default.\n\n## Community\n\n- Report bugs in the issue tracker.\n- Add the `acme-agent` topic to your plugin repository.\n" + "content": "# Acme Agent\n\nEnglish | [中文](product.zh.md)\n\nAcme Agent is an open-source agent harness that automates repository chores.\n\nThis paragraph contains neutral placeholder text for the example.\n\n## Install\n\nInstall Node.js 24, then run:\n\n```sh\nnpx acme-agent setup\n```\n\nThe command prints the setup URL, which is `http://127.0.0.1:3080` by default.\n\n## Community\n\n- Report bugs in the issue tracker.\n- Add the `acme-agent` topic to your plugin repository.\n" }, { "role": "assistant", - "content": "# Acme Agent\n\n[English](product.md) | 中文\n\nAcme Agent 是一款开源 agent harness(智能体框架),用于自动化仓库日常事务。\n\n它完全离线运行。**不会传输任何遥测数据。**\n\n## 安装\n\n安装 Node.js 24,然后运行:\n\n```sh\nnpx acme-agent setup\n```\n\n该命令会打印设置地址,默认地址为 `http://127.0.0.1:3080`。\n\n## 社区\n\n- 在 Issue 跟踪器中报告 bug。\n- 为你的插件仓库添加 `acme-agent` 主题。\n" + "content": "# Acme Agent\n\n[English](product.md) | 中文\n\nAcme Agent 是一款开源 agent harness(智能体框架),用于自动化仓库日常事务。\n\n本段包含供示例使用的中性占位文本。\n\n## 安装\n\n安装 Node.js 24,然后运行:\n\n```sh\nnpx acme-agent setup\n```\n\n该命令会打印设置地址,默认地址为 `http://127.0.0.1:3080`。\n\n## 社区\n\n- 在 Issue 跟踪器中报告 bug。\n- 为你的插件仓库添加 `acme-agent` 主题。\n" }, { "role": "user", From 668da7f507afb7404bfc6e4721e34f541d8a4f44 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:08:46 +0800 Subject: [PATCH 006/248] refactor(win32-process): share native process primitives --- ...-shared-win32-process-primitives.i18n.yaml | 6 + ...6-08-19-shared-win32-process-primitives.md | 35 ++ ...8-19-shared-win32-process-primitives.zh.md | 35 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 2 +- .../sandbox/sandbox-windows-acl/README.zh.md | 2 +- .../sandbox/sandbox-windows-acl/package.json | 1 + .../sandbox/sandbox-windows-acl/src/errors.ts | 21 - .../sandbox/sandbox-windows-acl/src/ffi.ts | 595 ++++++------------ .../sandbox/sandbox-windows-acl/src/index.ts | 54 +- .../sandbox/sandbox-windows-acl/src/spawn.ts | 375 ++--------- .../sandbox-windows-acl/src/win32-abi.ts | 296 ++------- .../tests/acl-failure-paths.spec.ts | 6 +- .../sandbox-windows-acl/tests/ffi.spec.ts | 24 +- .../tests/grant-failure-paths.spec.ts | 4 +- .../tests/index-failure-paths.spec.ts | 66 +- .../sandbox-windows-acl/tests/quote.spec.ts | 88 --- .../tests/token-failure-paths.spec.ts | 6 +- .../sandbox/sandbox-windows-acl/tsconfig.json | 3 + .../sandbox-windows-acl/verify/abi-probe.cpp | 245 ++------ packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 1 + packages/subprocess/README.zh.md | 1 + .../subprocess/win32-process/README.i18n.yaml | 6 + packages/subprocess/win32-process/README.md | 39 ++ .../subprocess/win32-process/README.zh.md | 39 ++ .../subprocess/win32-process/package.json | 45 ++ packages/subprocess/win32-process/src/abi.ts | 38 ++ .../subprocess/win32-process/src/errors.ts | 14 + packages/subprocess/win32-process/src/ffi.ts | 319 ++++++++++ .../subprocess/win32-process/src/index.ts | 29 + .../subprocess/win32-process/src/invariant.ts | 17 + .../subprocess/win32-process/src/process.ts | 431 +++++++++++++ .../win32-process/tests/ffi.spec.ts | 45 ++ .../win32-process/tests/invariant.spec.ts | 16 + .../tests/process-allocation-failure.spec.ts | 145 +++++ .../tests/process-failure-paths.spec.ts} | 179 +++--- .../win32-process/tests/process.spec.ts | 243 +++++++ .../win32-process/tests/quote.spec.ts | 65 ++ .../subprocess/win32-process/tsconfig.json | 13 + .../win32-process/verify/abi-probe.cpp | 48 ++ pnpm-lock.yaml | 16 + tsconfig.host.json | 1 + 49 files changed, 2239 insertions(+), 1399 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md create mode 100644 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md delete mode 100644 packages/sandbox/sandbox-windows-acl/src/errors.ts delete mode 100644 packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts create mode 100644 packages/subprocess/win32-process/README.i18n.yaml create mode 100644 packages/subprocess/win32-process/README.md create mode 100644 packages/subprocess/win32-process/README.zh.md create mode 100644 packages/subprocess/win32-process/package.json create mode 100644 packages/subprocess/win32-process/src/abi.ts create mode 100644 packages/subprocess/win32-process/src/errors.ts create mode 100644 packages/subprocess/win32-process/src/ffi.ts create mode 100644 packages/subprocess/win32-process/src/index.ts create mode 100644 packages/subprocess/win32-process/src/invariant.ts create mode 100644 packages/subprocess/win32-process/src/process.ts create mode 100644 packages/subprocess/win32-process/tests/ffi.spec.ts create mode 100644 packages/subprocess/win32-process/tests/invariant.spec.ts create mode 100644 packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts rename packages/{sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts => subprocess/win32-process/tests/process-failure-paths.spec.ts} (71%) create mode 100644 packages/subprocess/win32-process/tests/process.spec.ts create mode 100644 packages/subprocess/win32-process/tests/quote.spec.ts create mode 100644 packages/subprocess/win32-process/tsconfig.json create mode 100644 packages/subprocess/win32-process/verify/abi-probe.cpp diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml new file mode 100644 index 0000000000..053fadffc3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +2026-08-19-shared-win32-process-primitives.md: ab23b02dfb4e937891b26b009900696ada3fa3c0 +2026-08-19-shared-win32-process-primitives.zh.md: e8686d9f4d1ac2d05c0eecf025ada19d491e50d2 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md new file mode 100644 index 0000000000..ab23b02dfb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -0,0 +1,35 @@ +# Agent Note: Windows sandbox process primitives have one low-level owner + +Status: implemented + +English | [中文](2026-08-19-shared-win32-process-primitives.zh.md) + +## Problem + +The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace policy, but its process launch path also carried the generic Koffi ABI, command-line quoting, anonymous pipes, inherited stdio, Job setup, waits, and HANDLE cleanup. A second Windows process consumer would otherwise have to depend on sandbox policy or copy native resource logic, while fixes to allocation and failure cleanup would need to remain synchronized. + +## Decision + +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. + +The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. + +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Successful inherited-stdio creation returns the process plus kill-on-close Job after the child is suspended, assigned to the Job, and resumed; assignment failure terminates the suspended child before releasing its handles. The sandbox owns returned process, pipe, and Job handles until wait or disposal. + +The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. + +## Verification + +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended Job assignment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile the header probe and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. + +## Alternatives considered + +**Keep process primitives inside the sandbox package.** Rejected because a process consumer would inherit ACL/token policy or duplicate the native ABI and cleanup paths. + +**Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. + +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused `CreateProcessW`, application-name, parent-stdio, and Job-settlement APIs would freeze speculative obligations and enlarge the failure matrix. + +## Consequences + +The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Future process consumers extend the low-level package only when their production path exists. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md new file mode 100644 index 0000000000..e8686d9f4d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -0,0 +1,35 @@ +# Agent Note:Windows sandbox process primitives 只有一个低层 owner + +Status: implemented + +[English](2026-08-19-shared-win32-process-primitives.md) | 中文 + +## Problem + +Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace policy,但其进程启动路径还同时承载通用 Koffi ABI、命令行引用、匿名管道、继承 stdio、Job 设置、wait 与 HANDLE 清理。第二个 Windows process consumer 否则只能依赖 sandbox policy 或复制 native resource 逻辑,而 allocation 与失败清理修复也必须在多份实现间保持同步。 + +## Decision + +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 + +Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 + +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建成功时,child 已 suspended、指派给 Job 并 resume,随后把 process 与 kill-on-close Job 返回给 sandbox;指派失败会先终止 suspended child,再释放其 handles。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 + +该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 + +## Verification + +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的 suspended Job 指派、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 + +## Alternatives considered + +**把 process primitives 留在 sandbox package。** 拒绝,因为 process consumer 将被迫继承 ACL/token policy,或复制 native ABI 与清理路径。 + +**为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 + +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的 `CreateProcessW`、application-name、parent-stdio 与 Job-settlement API 会冻结推测性义务,并扩大失败矩阵。 + +## Consequences + +sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。后续 process consumer 只在其生产路径存在时扩展低层 package。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09be282242..e1c29a414a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: c379a7a49e4aa670aac3aa203e216b2be8e1955d -config-catalog.zh.md: e897f5d25a485133d4929061dce0b398edfa8c04 +config-catalog.md: fc8c694de61fa66b472b7b825fa0e984b1e4a044 +config-catalog.zh.md: bcef55e34e414de2233f3d0d0964683ea64b61ec diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c379a7a49e..fc8c694de6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3222,3 +3222,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-win32-process` ([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e897f5d25a..bcef55e34e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3225,3 +3225,4 @@ export interface Config { - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-win32-process`([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 144729d0c9..9d7dc49ce8 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 54aa13217a01ed44b44925365526438d4c867928 -module-graph.zh.md: 33c2f53afeb94c6d844f8406c1043d780436f588 +module-graph.md: 207a4ae20f24e4ff369ac154272b916fe6a2c7da +module-graph.zh.md: fdb05d6f92184ec72bdbe59e4022313d91827aa7 diff --git a/docs/module-graph.md b/docs/module-graph.md index 54aa13217a..207a4ae20f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -299,6 +299,7 @@ flowchart TD subgraph group_subprocess["packages/subprocess"] pkg_subprocess["subprocess"] pkg_subprocess_local["subprocess-local"] + pkg_win32_process["win32-process"] end subgraph group_terminal["packages/terminal"] pkg_terminal["terminal"] @@ -352,6 +353,7 @@ flowchart TD pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_protocol --> pkg_invariants @@ -1438,6 +1440,7 @@ flowchart TD | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-protocol`](../packages/typert/protocol) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 33c2f53afe..fdb05d6f92 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -301,6 +301,7 @@ flowchart TD subgraph group_subprocess["packages/subprocess"] pkg_subprocess["subprocess"] pkg_subprocess_local["subprocess-local"] + pkg_win32_process["win32-process"] end subgraph group_terminal["packages/terminal"] pkg_terminal["terminal"] @@ -354,6 +355,7 @@ flowchart TD pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_protocol --> pkg_invariants @@ -1440,6 +1442,7 @@ flowchart TD | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-protocol`](../packages/typert/protocol) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index a394957f49..ace32ae8cf 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.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 packages/sandbox/sandbox-windows-acl/README.md -README.md: a78f334342196ec1848a4a360e5c60b375a28057 -README.zh.md: 8962653b69b23b92fe763e4fcc90bf45911865f7 +README.md: c31f6452815c5629b49c302ebec408da1f0f4803 +README.zh.md: c6a87075875d3424b47121e32d8465a752149c89 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index a78f334342..c31f645281 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -38,7 +38,7 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work rmSync(tempDir, { recursive: true, force: true }) ``` -A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 8962653b69..c6a8707587 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -38,7 +38,7 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work rmSync(tempDir, { recursive: true, force: true }) ``` -直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 +直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。每个 policy-specific Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.md) 提供的 process primitive 都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 817d52e48f..c30a34f466 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-win32-process": "workspace:^", "koffi": "^3.1.0" }, "devDependencies": { diff --git a/packages/sandbox/sandbox-windows-acl/src/errors.ts b/packages/sandbox/sandbox-windows-acl/src/errors.ts deleted file mode 100644 index b57d6dd466..0000000000 --- a/packages/sandbox/sandbox-windows-acl/src/errors.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Fail-closed Win32 error type. Every backend API failure raises this with the - * API name and the exact Win32 code; the original POC silently ignored every - * failed call and would run children UNRESTRICTED (fail-open) — that is the - * failure mode this class exists to prevent. - * @module @deepseek-ai/dsh-sandbox-windows-acl/errors - */ - -export class Win32Error extends Error { - /** The failing Win32 API name, e.g. `CreateRestrictedToken`. */ - readonly api: string - /** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */ - readonly win32Code: number - - constructor(api: string, win32Code: number, detail?: string) { - super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) - this.name = 'Win32Error' - this.api = api - this.win32Code = win32Code - } -} diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 698f0dc2ee..18e262a66b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -1,512 +1,297 @@ -/** - * Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so - * non-Windows processes never open Win32 libraries. Every function signature - * below was verified against the MinGW Windows headers on this machine - * (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h / - * processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h); - * struct layouts are asserted at load time against verify/abi-probe.cpp. - * @module @deepseek-ai/dsh-sandbox-windows-acl/ffi - */ +/** ACL/token bindings layered on the shared Win32 process owner. */ import koffi from 'koffi' -import { Win32Error } from './errors.ts' +import { + ERROR_INSUFFICIENT_BUFFER, + Win32Error, + extendWin32ProcessBindings, + isNullPtr, + throwLastError, +} from '@deepseek-ai/dsh-win32-process' +import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' import * as abi from './win32-abi.ts' -/** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ -declare const nativePtr: unique symbol -/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ -export type NativePtr = bigint & { readonly [nativePtr]: true } +export { + allocPtrSlot, + allocUint32, + decodePtr, + decodeUint32, + isNullPtr, + throwLastError, + throwWin32, +} from '@deepseek-ai/dsh-win32-process' +export type { NativePtr } from '@deepseek-ai/dsh-win32-process' -/** - * True for NULL pointers, however koffi returns them (null or 0n). - * @param value - a pointer as koffi may hand it back (pointer, null, or 0n). - * @returns a type guard narrowing to the NULL shapes. - */ -export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { - return value === null || value === undefined || (value as bigint) === 0n +type Ptr = ReturnType +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +/** ACL/token calls composed with the generic Win32 process binding table. */ +export interface Win32Bindings extends Win32ProcessBindings { + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number + localAlloc(flags: number, bytes: number): NativePtr + localFree(memory: NativePtr): NativePtr + convertStringSidToSidW(stringSid: string, sid: NativePtr): number + createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number + isValidSid(sid: NativePtr): number + getLengthSid(sid: NativePtr): number + copySid(length: number, destination: NativePtr, source: NativePtr): number + getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number + createRestrictedToken( + existing: NativePtr, + flags: number, + disableCount: number, + disableSids: null, + deletePrivilegeCount: number, + privilegesToDelete: null, + restrictCount: number, + restrictingSids: Buffer, + newToken: NativePtr, + ): number + setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + setNamedSecurityInfoW( + path: string, + objectType: number, + information: number, + owner: null, + group: null, + dacl: NativePtr | null, + sacl: null, + ): number + getNamedSecurityInfoW( + path: string, + objectType: number, + information: number, + owner: NativePtr, + group: NativePtr, + dacl: NativePtr, + sacl: NativePtr, + descriptor: NativePtr, + ): number + getTempPathW(length: number, buffer: Buffer): number + setEnvironmentVariableW(name: string, value: string): number + setConsoleCtrlHandler(handler: null, add: number): number + createFileW( + fileName: string, + desiredAccess: number, + shareMode: number, + attributes: null, + creationDisposition: number, + flagsAndAttributes: number, + templateFile: null, + ): NativePtr + lockFileEx( + file: NativePtr, + flags: number, + reserved: number, + bytesLow: number, + bytesHigh: number, + overlapped: NativePtr, + ): number + unlockFileEx( + file: NativePtr, + reserved: number, + bytesLow: number, + bytesHigh: number, + overlapped: NativePtr, + ): number } /** - * True for CreateFileW's INVALID_HANDLE_VALUE failure marker (-1, which - * koffi hands back as the unsigned 64-bit all-ones pointer). - * @param handle - the handle CreateFileW returned. - * @returns whether the handle signals failure. + * Return whether CreateFileW produced INVALID_HANDLE_VALUE. + * @param handle - handle returned by CreateFileW. + * @returns true for null, zero, or the all-bits-one sentinel. */ export function isInvalidHandle(handle: NativePtr | null | undefined): boolean { if (isNullPtr(handle)) return true return (handle as bigint) === 0xFFFFFFFFFFFFFFFFn || (handle as bigint) === -1n } -type Ptr = ReturnType - -/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ -export interface StartupInfoInput { - cb: number - dwFlags: number - hStdInput: NativePtr - hStdOutput: NativePtr - hStdError: NativePtr -} - -/** Decoded PROCESS_INFORMATION (layout verified: size 24). */ -export interface ProcessInfoOutput { - hProcess: NativePtr | null - hThread: NativePtr | null - dwProcessId: number - dwThreadId: number -} - -/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */ -export interface Win32Bindings { - // ---- process / token handles -------------------------------------------- - openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr - openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number - closeHandle(handle: NativePtr): number - // ---- errors / diagnostics ------------------------------------------------ - getLastError(): number - formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number - // ---- memory -------------------------------------------------------------- - localAlloc(flags: number, bytes: number): NativePtr - localFree(memory: NativePtr): NativePtr - // ---- SIDs ---------------------------------------------------------------- - convertStringSidToSidW(stringSid: string, sid: NativePtr): number - createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number - isValidSid(sid: NativePtr): number - getLengthSid(sid: NativePtr): number - copySid(length: number, destination: NativePtr, source: NativePtr): number - // ---- token information --------------------------------------------------- - getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number - setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number - // ---- restricted token ---------------------------------------------------- - createRestrictedToken( - existing: NativePtr, flags: number, - disableCount: number, disableSids: null, - deletePrivilegeCount: number, privilegesToDelete: null, - restrictCount: number, restrictingSids: Buffer, - newToken: NativePtr, - ): number - // ---- ACL editing --------------------------------------------------------- - setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number - setNamedSecurityInfoW( - path: string, objectType: number, information: number, - owner: null, group: null, dacl: NativePtr | null, sacl: null, - ): number - getNamedSecurityInfoW( - path: string, objectType: number, information: number, - owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, - ): number - // ---- environment / io ---------------------------------------------------- - getTempPathW(length: number, buffer: Buffer): number - createFileW( - fileName: string, desiredAccess: number, shareMode: number, attributes: null, - creationDisposition: number, flagsAndAttributes: number, templateFile: null, - ): NativePtr - lockFileEx(file: NativePtr, flags: number, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number - unlockFileEx(file: NativePtr, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number - createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number - setHandleInformation(handle: NativePtr, mask: number, flags: number): number - createProcessAsUserW( - token: NativePtr, applicationName: null, commandLine: string, - processAttributes: null, threadAttributes: null, - inheritHandles: number, creationFlags: number, environment: null, - currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, - ): number - setEnvironmentVariableW(name: string, value: string): number - readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number - peekNamedPipe( - pipe: NativePtr, buffer: null, size: number, - bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr, - ): number - waitForSingleObject(handle: NativePtr, milliseconds: number): number - getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number - resumeThread(thread: NativePtr): number - // ---- job object (runner kill-on-close) ----------------------------------- - createJobObjectW(attributes: null, name: null): NativePtr - setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number - assignProcessToJobObject(job: NativePtr, process: NativePtr): number - // Terminate a suspended child that could not be placed in the kill-on-close - // job — closing handles alone would leave it hanging forever. - terminateProcess(process: NativePtr, exitCode: number): number - // ---- console ------------------------------------------------------------- - // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): - // the runner survives console Ctrl+C so the child handles its own and the - // runner can clean up grants after the child exits. - setConsoleCtrlHandler(handler: null, add: number): number - getStdHandle(stdHandle: number): NativePtr -} - -const PVOID: Ptr = koffi.pointer('void') -const PPVOID: Ptr = koffi.pointer(PVOID) - -/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */ -export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { - cb: 'uint32', - lpReserved: 'str16', - lpDesktop: 'str16', - lpTitle: 'str16', - dwX: 'uint32', - dwY: 'uint32', - dwXSize: 'uint32', - dwYSize: 'uint32', - dwXCountChars: 'uint32', - dwYCountChars: 'uint32', - dwFillAttribute: 'uint32', - dwFlags: 'uint32', - wShowWindow: 'uint16', - cbReserved2: 'uint16', - lpReserved2: koffi.pointer('uint8'), - hStdInput: PVOID, - hStdOutput: PVOID, - hStdError: PVOID, -}) - -/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */ -export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { - hProcess: PVOID, - hThread: PVOID, - dwProcessId: 'uint32', - dwThreadId: 'uint32', -}) - -/* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */ -if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { - throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) -} -if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { - throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) -} -/* v8 ignore stop */ - /** - * Allocate one pointer-sized slot (for `T **` out-parameters). - * @returns the allocated slot pointer. - */ -export function allocPtrSlot(): NativePtr { - const value: unknown = koffi.alloc(PVOID, 1) - return value as NativePtr -} - -/** - * Allocate one uint32 slot. - * @returns the allocated slot pointer. - */ -export function allocUint32(): NativePtr { - const value: unknown = koffi.alloc('uint32', 1) - return value as NativePtr -} - -/** - * Write a uint32 value into a slot pointer. - * @param slot - the slot allocated by {@link allocUint32}. - * @param value - the uint32 to encode. + * Encode a uint32 into an allocated slot. + * @param slot - slot allocated by allocUint32. + * @param value - unsigned value to store. */ export function encodeUint32(slot: NativePtr, value: number): void { koffi.encode(slot, 'uint32', value) } /** - * Decode the pointer stored in a pointer-sized slot (NULL becomes null). - * @param slot - the pointer-sized slot holding the out-parameter value. - * @returns the decoded pointer, or null for NULL. - */ -export function decodePtr(slot: NativePtr): NativePtr | null { - const value: unknown = koffi.decode(slot, PVOID) - if (isNullPtr(value as NativePtr | null | undefined)) return null - return value as NativePtr -} - -/** - * Decode a uint32 at a slot pointer. - * @param slot - the uint32 slot holding the out-parameter value. - * @returns the decoded uint32. - */ -export function decodeUint32(slot: NativePtr): number { - const value: unknown = koffi.decode(slot, 'uint32') - return value as number -} - -/** - * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). - * @param ptr - the koffi pointer. - * @returns the pointer's numeric address. + * Return a Koffi pointer's numeric address for struct packing. + * @param ptr - native pointer. + * @returns pointer address. */ export function ptrAddress(ptr: NativePtr): bigint { return koffi.address(ptr) } /** - * Allocate a raw byte block (used for SID copies and variable-length arrays). - * @param length - the block size in bytes. - * @returns the allocated block pointer. + * Allocate a raw byte block. + * @param length - byte count. + * @returns allocated pointer. */ export function allocBytes(length: number): NativePtr { - const value: unknown = koffi.alloc('uint8', length) - return value as NativePtr + return koffi.alloc('uint8', length) as NativePtr } /** - * Allocate one zeroed OVERLAPPED (32 bytes on x64: Internal@0, InternalHigh@8, - * Offset@16, OffsetHigh@20, hEvent@24). LockFileEx/UnlockFileEx receive this - * instead of a NULL lpOverlapped: koffi 3.1.1 crashes on NULL there, and a - * zeroed OVERLAPPED on a synchronous file handle is the documented equivalent - * (the byte range locks from offset 0, hEvent stays NULL). - * @returns the zeroed block pointer. + * Allocate one zeroed x64 OVERLAPPED record. + * @returns allocated pointer. */ export function allocOverlapped(): NativePtr { return allocBytes(32) } /** - * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). - * @param buffer - the buffer holding the pointer value. - * @param offset - byte offset of the pointer inside the buffer. - * @returns the decoded pointer, or null for NULL. + * Decode a pointer value from a Buffer field. + * @param buffer - encoded native record. + * @param offset - pointer field byte offset. + * @returns decoded pointer, or null for address zero. */ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { - const value: unknown = koffi.decode(buffer, offset, PVOID) - if (isNullPtr(value as NativePtr | null | undefined)) return null - return value as NativePtr + const value = koffi.decode(buffer, offset, PVOID) as NativePtr | null + return isNullPtr(value) ? null : value } /** - * Decode a uint8 at a native pointer plus byte offset — the ACL walk's - * field-read primitive (koffi.decode with an offset, no memcpy, no pointer - * arithmetic). - * @param ptr - the native pointer to read from. - * @param offset - byte offset from the pointer. - * @returns the decoded uint8. + * Decode a uint8 field at a native pointer offset. + * @param ptr - native record pointer. + * @param offset - field byte offset. + * @returns decoded value. */ export function decodeUint8At(ptr: NativePtr, offset: number): number { - const value: unknown = koffi.decode(ptr, offset, 'uint8') - return value as number + return koffi.decode(ptr, offset, 'uint8') as number } /** - * Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}). - * @param ptr - the native pointer to read from. - * @param offset - byte offset from the pointer. - * @returns the decoded uint16. + * Decode a uint16 field at a native pointer offset. + * @param ptr - native record pointer. + * @param offset - field byte offset. + * @returns decoded value. */ export function decodeUint16At(ptr: NativePtr, offset: number): number { - const value: unknown = koffi.decode(ptr, offset, 'uint16') - return value as number + return koffi.decode(ptr, offset, 'uint16') as number } /** - * Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}). - * @param ptr - the native pointer to read from. - * @param offset - byte offset from the pointer. - * @returns the decoded uint32. + * Decode a uint32 field at a native pointer offset. + * @param ptr - native record pointer. + * @param offset - field byte offset. + * @returns decoded value. */ export function decodeUint32At(ptr: NativePtr, offset: number): number { - const value: unknown = koffi.decode(ptr, offset, 'uint32') - return value as number + return koffi.decode(ptr, offset, 'uint32') as number } /** - * Compare two SIDs field-by-field via BOUNDED offset reads (revision, count, - * identifier authority, subauthorities up to the count) — never a fixed-size - * struct decode, which would read past a short SID allocation (a SID with - * fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible - * subauthority count reads as unequal. - * @param left - pointer to one SID (offset 0). - * @param leftOffset - byte offset of the SID structure within `left`. - * @param right - pointer to the other SID. - * @param rightOffset - byte offset of the SID structure within `right`. - * @returns whether the SIDs are identical. + * Compare two in-memory SID records without allocating strings. + * @param left - first native buffer. + * @param leftOffset - first SID byte offset. + * @param right - second native buffer. + * @param rightOffset - second SID byte offset. + * @returns true when revision, authority, and every sub-authority match. */ -export function sameSidAt(left: NativePtr, leftOffset: number, right: NativePtr, rightOffset: number): boolean { - const leftRevision = decodeUint8At(left, leftOffset) - const rightRevision = decodeUint8At(right, rightOffset) - if (leftRevision !== rightRevision) return false +export function sameSidAt( + left: NativePtr, + leftOffset: number, + right: NativePtr, + rightOffset: number, +): boolean { + if (decodeUint8At(left, leftOffset) !== decodeUint8At(right, rightOffset)) return false const leftCount = decodeUint8At(left, leftOffset + 1) const rightCount = decodeUint8At(right, rightOffset + 1) if (leftCount !== rightCount || leftCount > abi.SID_MAX_SUB_AUTHORITIES) return false - for (let index = 0; index < 6; index++) { - if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false + for (let index = 0; index < 6; index += 1) { + if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) { + return false + } } - for (let index = 0; index < leftCount; index++) { - if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false + for (let index = 0; index < leftCount; index += 1) { + if (decodeUint32At(left, leftOffset + 8 + index * 4) !== + decodeUint32At(right, rightOffset + 8 + index * 4)) return false } return true } -/** - * Allocate a zeroed STARTUPINFOW. - * @returns the allocated struct pointer. - */ -export function allocStartupInfo(): NativePtr { - const value: unknown = koffi.alloc(STARTUPINFOW, 1) - return value as NativePtr -} - -/** - * Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). - * @param startupInfo - the allocated STARTUPINFOW to encode into. - * @param fields - the field subset to write. - */ -export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { - koffi.encode(startupInfo, STARTUPINFOW, fields) -} - -/** - * Allocate a zeroed PROCESS_INFORMATION. - * @returns the allocated struct pointer. - */ -export function allocProcessInfo(): NativePtr { - const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) - return value as NativePtr -} - -/** - * Decode a PROCESS_INFORMATION after CreateProcessAsUserW. - * @param processInfo - the PROCESS_INFORMATION filled by the spawn call. - * @returns the decoded handle/id fields. - */ -export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { - const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) - return value as ProcessInfoOutput -} - let cached: Win32Bindings | undefined function bindings(): Win32Bindings { if (cached !== undefined) return cached - const kernel32 = koffi.load('kernel32.dll') - const advapi32 = koffi.load('advapi32.dll') - - // Each binding shape is verified by verify/abi-probe.cpp against the real - // Windows headers and exercised end-to-end by tests/probe.spec.ts; the - // single cast keeps the per-binding noise out of this table. - const bind = (lib: ReturnType, name: string, result: Ptr | string, args: Array): unknown => - lib.func('__stdcall', name, result, args) - - cached = { + cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({ openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), - closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), - getLastError: bind(kernel32, 'GetLastError', 'uint32', []), - formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', ['uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID]), localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), - createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), + createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', [ + 'int', PVOID, PVOID, koffi.pointer('uint32'), + ]), isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), - getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), - setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']), - createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), - setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), - setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), - getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), - getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), - // fileapi.h line ~64: HANDLE CreateFileW(LPCWSTR, DWORD, DWORD, - // LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE). - createFileW: bind(kernel32, 'CreateFileW', PVOID, ['str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID]), - // fileapi.h lines ~177/~185: BOOL LockFileEx(HANDLE, DWORD, DWORD, DWORD, - // DWORD, LPOVERLAPPED); BOOL UnlockFileEx(HANDLE, DWORD, DWORD, DWORD, - // LPOVERLAPPED). lpOverlapped is NULL for synchronous locking. - lockFileEx: bind(kernel32, 'LockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID]), - unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), - createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), - setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), - createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ - PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', - koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [ + PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32'), ]), + setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']), + createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [ + PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID, + ]), + setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', [ + 'str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID, + ]), + getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', [ + 'str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID, + ]), + getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']), - readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), - peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), - waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), - getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), - resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), - createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), - setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), - assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), - terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), - getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), - } as unknown as Win32Bindings + createFileW: bind(kernel32, 'CreateFileW', PVOID, [ + 'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID, + ]), + lockFileEx: bind(kernel32, 'LockFileEx', 'int', [ + PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID, + ]), + unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [ + PVOID, 'uint32', 'uint32', 'uint32', PVOID, + ]), + })) as unknown as Win32Bindings return cached } /** - * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). - * @returns the cached binding table. + * Resolve the cached ACL/token binding table asynchronously. + * @returns generic process plus ACL/token bindings. */ export function win32(): Promise { return Promise.resolve(bindings()) } /** - * Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's - * server-side per-session grant materializes ACEs inside the synchronous - * `confine()` call, which cannot await. Same cached table as {@link win32} - * (the underlying koffi loads are synchronous; the async wrapper exists for - * the runner's await-shaped call sites). - * @returns the cached binding table. + * Resolve the cached ACL/token binding table synchronously. + * @returns generic process plus ACL/token bindings. */ export function win32Sync(): Win32Bindings { return bindings() } /** - * Turn a Win32 error code into readable text via FormatMessageW. - * @param api - the binding table. - * @param win32Code - the error code to format. - * @returns the formatted message text, or '' when formatting fails. - */ -export function errorText(api: Win32Bindings, win32Code: number): string { - const buffer = Buffer.alloc(1024) - const length = api.formatMessageW( - abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, - null, win32Code, 0, buffer, buffer.length / 2, null, - ) - if (length === 0) return '' - return buffer.subarray(0, length * 2).toString('utf16le').trim() -} - -/** - * Read the process temp directory via GetTempPathW (fileapi.h line ~188). - * Defensive against an overlong system temp path: GetTempPathW reports the - * REQUIRED length (including NUL) without writing the buffer when it is too - * small, so a reported length beyond the buffer's capacity means the buffer - * was never filled and must not be decoded. - * @param api - the binding table. - * @returns the NUL-terminated temp path decoded as a string. + * Resolve the current Windows temporary directory. + * @param api - active ACL/token binding table. + * @returns UTF-16 path reported by GetTempPathW. */ export function getTempPath(api: Win32Bindings): string { const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) const length = api.getTempPathW(buffer.length / 2, buffer) if (length === 0) throwLastError(api, 'GetTempPathW') if (length > buffer.length / 2) { - throw new Win32Error('GetTempPathW', abi.ERROR_INSUFFICIENT_BUFFER, `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`) + throw new Win32Error( + 'GetTempPathW', + ERROR_INSUFFICIENT_BUFFER, + `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`, + ) } return buffer.subarray(0, length * 2).toString('utf16le') } - -/** - * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately - * after the failed call so GetLastError is not clobbered by other Win32 calls. - * @param api - the binding table. - * @param name - the failed API's name for the error message. - * @param detail - optional detail overriding the formatted system message. - * @returns never — always throws. - */ -export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { - const win32Code = api.getLastError() - throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) -} - -/** - * Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). - * @param api - the binding table. - * @param name - the failed API's name for the error message. - * @param win32Code - the API's returned error code. - * @param detail - optional detail overriding the formatted system message. - * @returns never — always throws. - */ -export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { - throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) -} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index cf304b1904..40d0d47a1d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -42,9 +42,9 @@ import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' +import { closeHandleChecked, Win32Error } from '@deepseek-ai/dsh-win32-process' import { grantWrite, revokeWrite } from './acl.ts' -import { Win32Error } from './errors.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { assertPrivateTempDisjoint } from './path-boundary.ts' @@ -52,11 +52,9 @@ import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' -export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' -export { Win32Error } from './errors.ts' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { @@ -357,15 +355,27 @@ export class AclSandbox { if (options.stdio === 'inherit') { const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) - let exitCodePromise: Promise | undefined + let settlement: Promise | undefined return { pid: native.pid, - wait: async () => { - exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) - const exitCode = await exitCodePromise - if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. + wait: () => (settlement ??= (async () => { + const failures: unknown[] = [] + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + try { + closeHandleChecked(api, native.job, 'kill-on-close job') + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } - }, + })()), } } @@ -376,15 +386,27 @@ export class AclSandbox { // the thread and would starve the drains while the child is still running // (pipe-buffer deadlock). The drains resolve only after the child closed // its pipe ends — by then the wait returns immediately. - let exitCodePromise: Promise | undefined + let settlement: Promise | undefined return { pid: native.pid, - wait: async () => { - const stdoutBuffer = await stdout - const stderrBuffer = await stderr - exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) - return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } - }, + wait: () => (settlement ??= (async () => { + const drains = await Promise.allSettled([stdout, stderr]) + const failures = drains.flatMap(outcome => + outcome.status === 'rejected' ? [outcome.reason as unknown] : []) + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') + return { + stdout: (drains[0] as PromiseFulfilledResult).value, + stderr: (drains[1] as PromiseFulfilledResult).value, + exitCode, + } + })()), } } diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index eafcb252ce..a36b0253c4 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -1,357 +1,60 @@ -/** - * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with - * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then - * asynchronous pipe draining and exit waiting. Console isolation - * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this - * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED - * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is - * pipe-based and unaffected; the child shares the host console. - * @module @deepseek-ai/dsh-sandbox-windows-acl/spawn - */ +/** Restricted-token adapters over the shared Win32 process owner. */ -import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts' -import type { NativePtr, Win32Bindings } from './ffi.ts' -import * as abi from './win32-abi.ts' +import { + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from '@deepseek-ai/dsh-win32-process' +import type { + NativePtr, + SpawnedJobProcess, + SpawnedPipedProcess, +} from '@deepseek-ai/dsh-win32-process' +import type { Win32Bindings } from './ffi.ts' + +export { drainPipe } from '@deepseek-ai/dsh-win32-process' + +/** Restricted-token child with piped stdio resources. */ +export interface SpawnedNative extends SpawnedPipedProcess {} +/** Restricted-token child assigned to a kill-on-close Job. */ +export interface SpawnedInherited extends SpawnedJobProcess {} /** - * Quote one argument per the CommandLineToArgvW parsing rules: backslashes - * are doubled only before a quote character — including the closing quote - * this function appends, so a trailing backslash run is doubled as well - * (otherwise an odd run would escape the closing quote into a literal - * character and corrupt the rest of the command line). Mirrors the CRT - * ArgvQuote behavior Microsoft documents for command-line arguments. - * @param argument - one argv entry to quote. - * @returns the quoted entry (bare when quoting is unnecessary). - */ -export function quoteArg(argument: string): string { - if (argument === '') return '""' - if (!/[\s"]/u.test(argument)) return argument - let quoted = '"' - for (let index = 0; index < argument.length; index++) { - let backslashes = 0 - while (index < argument.length && argument.charAt(index) === '\\') { - backslashes++ - index++ - } - if (index === argument.length) { - // Trailing backslash run: doubled so it cannot escape the closing quote. - quoted += '\\'.repeat(backslashes * 2) - } else if (argument.charAt(index) === '"') { - quoted += '\\'.repeat(backslashes * 2 + 1) + '"' - } else { - quoted += '\\'.repeat(backslashes) + argument.charAt(index) - } - } - return quoted + '"' -} - -/** - * Build the single command line CreateProcess parses from program + argv. - * @param program - the executable (argv[0]). - * @param args - the remaining argv entries. - * @returns the joined, quoted command line. - */ -export function buildCommandLine(program: string, args: readonly string[]): string { - return [program, ...args].map(quoteArg).join(' ') -} - -interface PipePair { - read: NativePtr - write: NativePtr -} - -function createPipe(api: Win32Bindings): PipePair { - const readSlot = allocPtrSlot() - const writeSlot = allocPtrSlot() - if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') - const read = decodePtr(readSlot) - const write = decodePtr(writeSlot) - if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle') - return { read, write } -} - -function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void { - if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { - throwLastError(api, 'SetHandleInformation', label) - } -} - -/** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */ -export interface SpawnedNative { - pid: number - process: NativePtr - stdoutRead: NativePtr - stderrRead: NativePtr -} - -/** - * Create a process under the restricted token with piped stdio. The child's - * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends - * are returned for draining. The child inherits the caller's environment block - * (lpEnvironment NULL); the caller rewrites entries through - * SetEnvironmentVariableW before spawning (the runner's per-session temp - * contract) — passing an explicit block through koffi trips - * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically). - * @param api - the binding table. - * @param token - the restricted token the child runs under. + * Spawn a restricted-token child with piped stdout/stderr. + * @param api - ACL/token binding table. + * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns the spawned child's handles. + * @returns process and caller-owned pipe handles. */ export function spawnSandboxed( api: Win32Bindings, token: NativePtr, options: { command: string; args: readonly string[]; cwd: string }, ): SpawnedNative { - const stdIn = createPipe(api) - const stdOut = createPipe(api) - const stdErr = createPipe(api) - // Child side of each pipe must be inheritable (POC lines 262-268). - setInheritable(api, stdIn.read, 'stdin read end') - setInheritable(api, stdOut.write, 'stdout write end') - setInheritable(api, stdErr.write, 'stderr write end') - - const startupInfo = allocStartupInfo() - encodeStartupInfo(startupInfo, { - cb: abi.STARTUPINFOW_SIZE, - dwFlags: abi.STARTF_USESTDHANDLES, - hStdInput: stdIn.read, - hStdOutput: stdOut.write, - hStdError: stdErr.write, - }) - - const processInfo = allocProcessInfo() - const commandLine = buildCommandLine(options.command, options.args) - const created = api.createProcessAsUserW( - token, null, commandLine, - null, null, - 1, // bInheritHandles: required for redirection - 0, // no creation flags: suspended/no-window variants are unusable under the restriction - null, options.cwd, - startupInfo, processInfo, - ) - // Capture the failure before CloseHandle calls clobber GetLastError, then - // close every pipe handle created so far — the six-close contract this test - // surface pins (tests/failure-paths.spec.ts). - if (created === 0) { - const win32Code = api.getLastError() - api.closeHandle(stdIn.read) - api.closeHandle(stdIn.write) - api.closeHandle(stdOut.read) - api.closeHandle(stdOut.write) - api.closeHandle(stdErr.read) - api.closeHandle(stdErr.write) - throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) - } - - const info = decodeProcessInfo(processInfo) - const processHandle = info.hProcess - const threadHandle = info.hThread - if (processHandle === null || threadHandle === null) { - throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) - } - - // Host-side cleanup: child handles are now duplicated in the child; the - // host closes its copies so ReadFile sees EOF when the child exits. - api.closeHandle(stdIn.read) - api.closeHandle(stdOut.write) - api.closeHandle(stdErr.write) - api.closeHandle(stdIn.write) - api.closeHandle(threadHandle) - - return { - pid: info.dwProcessId, - process: processHandle, - stdoutRead: stdOut.read, - stderrRead: stdErr.read, - } + return spawnPipedProcess(api, { ...options, token }) } /** - * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. - * @param api - the binding table. - * @param handle - the pipe read end to drain (closed when done). - * @returns the complete pipe contents. - */ -export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { - const chunks: Buffer[] = [] - for (;;) { - const bytesReadSlot = allocUint32() - const totalAvailSlot = allocUint32() - const leftThisMessageSlot = allocUint32() - const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot) - if (peeked === 0) { - const win32Code = api.getLastError() - if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF - throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) - } - const available = decodeUint32(totalAvailSlot) - if (available > 0) { - const chunk = Buffer.alloc(available) - const readSlot = allocUint32() - if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) { - throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) - } - chunks.push(chunk.subarray(0, decodeUint32(readSlot))) - } - // Small backoff instead of setImmediate: a bare next-tick would busy-poll - // the pipe at full event-loop speed while the child produces no output. - await new Promise(resolve => setTimeout(resolve, 1)) - } - api.closeHandle(handle) - return Buffer.concat(chunks) -} - -/** - * Wait for process exit and return its exit code. Call only after both drains - * have resolved — the drains finish when the child closed its pipe ends, i.e. - * the child has already exited, so this wait returns immediately. Calling it - * earlier would block the event loop and starve the drains (the pipe-buffer - * deadlock the POC comments warn about). - * @param api - the binding table. - * @param process - the child process handle (closed when done). - * @returns the child's exit code. - */ -export function waitForExit(api: Win32Bindings, process: NativePtr): number { - const waitResult = api.waitForSingleObject(process, abi.INFINITE) - if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') - const exitCodeSlot = allocUint32() - if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') - api.closeHandle(process) - return decodeUint32(exitCodeSlot) -} - -/** - * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at - * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout - * verified by abi-probe.cpp). When the caller dies with the job handle open, - * Windows terminates every process in the job — the orphan-child backstop. - * The caller keeps the returned handle open for the child's lifetime. - */ -function createKillOnCloseJob(api: Win32Bindings): NativePtr { - const job = api.createJobObjectW(null, null) - if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') - const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) - information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET) - if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) { - const win32Code = api.getLastError() - api.closeHandle(job) - throwWin32(api, 'SetInformationJobObject', win32Code) - } - return job -} - -/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */ -export interface SpawnedInherited { - pid: number - process: NativePtr - /** Kill-on-close job the child was placed in; caller closes it after the child exits. */ - job: NativePtr -} - -/** - * Create a process under the restricted token whose stdio passes straight - * through to the caller's pipes. This is the runner shape: the harness spawns - * the runner with piped stdio, and the runner's confined child writes to - * those same pipes. - * - * Node clears the inheritability of its stdio handles at startup - * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit - * bit around the call (libuv instead duplicates the handles; re-enabling is - * equivalent here and cheaper) and pass them explicitly via - * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles - * ("The handle is invalid", verified the hard way). The child starts - * suspended so it can be assigned to a kill-on-close job before it runs. - * @param api - the binding table. - * @param token - the restricted token the child runs under. + * Spawn a restricted-token child in a kill-on-close Job with inherited stdio. + * @param api - ACL/token binding table. + * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns the spawned child's handles and job. + * @returns process and Job handles after assignment and resume. */ export function spawnSandboxedInherited( api: Win32Bindings, token: NativePtr, options: { command: string; args: readonly string[]; cwd: string }, ): SpawnedInherited { - const job = createKillOnCloseJob(api) - const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE) - const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE) - const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE) - if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) { - api.closeHandle(job) - throwLastError(api, 'GetStdHandle', 'null standard handle') - } - - const makeInheritable = (handle: NativePtr, label: string): void => { - if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { - throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) - } - } - const restoreInherit = (handle: NativePtr): void => { - // Best-effort hygiene: the runner spawns nothing else; failures here must - // not mask the child outcome, so the result is deliberately unchecked. - api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) - } - makeInheritable(stdIn, 'stdin') - makeInheritable(stdOut, 'stdout') - makeInheritable(stdErr, 'stderr') - - const startupInfo = allocStartupInfo() - encodeStartupInfo(startupInfo, { - cb: abi.STARTUPINFOW_SIZE, - dwFlags: abi.STARTF_USESTDHANDLES, - hStdInput: stdIn, - hStdOutput: stdOut, - hStdError: stdErr, - }) - - const processInfo = allocProcessInfo() - const commandLine = buildCommandLine(options.command, options.args) - const created = api.createProcessAsUserW( - token, null, commandLine, - null, null, - 1, // bInheritHandles: the re-enabled std handles must be inheritable - abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution - null, options.cwd, - startupInfo, processInfo, - ) - restoreInherit(stdIn) - restoreInherit(stdOut) - restoreInherit(stdErr) - if (created === 0) { - const win32Code = api.getLastError() - api.closeHandle(job) - throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) - } - - const info = decodeProcessInfo(processInfo) - const processHandle = info.hProcess - const threadHandle = info.hThread - if (processHandle === null || threadHandle === null) { - api.closeHandle(job) - throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) - } - - if (api.assignProcessToJobObject(job, processHandle) === 0) { - // The child was created suspended and is NOT in the kill-on-close job: - // closing handles would leave it suspended forever. Terminate it first, - // then drop the handles and throw. - const win32Code = api.getLastError() - api.terminateProcess(processHandle, 1) - api.closeHandle(threadHandle) - api.closeHandle(processHandle) - api.closeHandle(job) - throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) - } - if (api.resumeThread(threadHandle) === 0xFFFFFFFF) { - // Closing the job triggers kill-on-close, so the suspended child dies - // instead of hanging until this process exits; the process/thread handles - // must go too. - const win32Code = api.getLastError() - api.closeHandle(threadHandle) - api.closeHandle(processHandle) - api.closeHandle(job) - throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) - } - api.closeHandle(threadHandle) - - return { pid: info.dwProcessId, process: processHandle, job } + return spawnInheritedJobProcess(api, { ...options, token }) +} + +/** + * Wait for a restricted child and close its process handle. + * @param api - ACL/token binding table. + * @param process - caller-owned process handle. + * @returns direct process exit code. + */ +export function waitForExit(api: Win32Bindings, process: NativePtr): number { + return waitForProcessExit(api, process) } diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 5af4496af7..019f894a6c 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -1,258 +1,94 @@ -/** - * Windows ABI constants for the ACL-sandbox backend. - * - * Every value was verified against the actual MinGW Windows headers on this - * machine (C:\Strawberry\c\x86_64-w64-mingw32\include\) and cross-checked at - * runtime by verify/abi-probe.cpp (same numbers; static_asserts passed). - * Regenerate the probe with: - * g++ -std=c++20 -municode -O2 -o abi-probe.exe abi-probe.cpp -ladvapi32 && .\abi-probe.exe - * - * The port intentionally excludes two pieces of the original POC - * (github.com/huoyaoyuan/windows-acl-restrict-poc @ 10e4dfb), both verified - * empirically on Windows 11 build 26200: - * - S-1-2-1 (console logon SID) in the restricting list: the POC created it - * via CreateWellKnownSid(WinLocalLogonSid) which fails here with - * ERROR_INVALID_PARAMETER (87), leaving a garbage SID that makes - * CreateRestrictedToken fail with ERROR_INVALID_SID (1337); using the - * correct WinConsoleLogonSid does produce a valid S-1-2-1, but the child - * then still dies with STATUS_DLL_INIT_FAILED (0xC0000142) whenever - * CREATE_NO_WINDOW / CREATE_NEW_CONSOLE is used. - * - Console isolation: under this restriction scheme a hidden console is not - * attainable, so children share the host console (stdio redirection is - * pipe-based and unaffected). - * @module @deepseek-ai/dsh-sandbox-windows-acl/win32-abi - */ +/** ACL/token-specific Win32 constants. */ -// ---- winnt.h --------------------------------------------------------------- - -// TOKEN_* access rights (winnt.h lines ~3928) -/** TOKEN_ASSIGN_PRIMARY: required to create a process with the token (CreateProcessAsUser). */ -export const TOKEN_ASSIGN_PRIMARY = 0x0001 -/** TOKEN_DUPLICATE: required to duplicate a token (DuplicateTokenEx). */ -export const TOKEN_DUPLICATE = 0x0002 -/** TOKEN_QUERY: required to read token information (GetTokenInformation). */ -export const TOKEN_QUERY = 0x0008 -/** TOKEN_ADJUST_DEFAULT: required to change a token's default DACL. */ -export const TOKEN_ADJUST_DEFAULT = 0x0080 - -// SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) -/** - * SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with - * `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number). - */ -export const SE_GROUP_LOGON_ID = 0xC0000000 - -// Generic file access (winnt.h lines ~5893-5913): -// FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES -// | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE -/** STANDARD_RIGHTS_WRITE (== READ_CONTROL): the standard-rights component of generic write access. */ -export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL -/** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ -export const FILE_GENERIC_WRITE = 0x00120116 -/** DELETE: remove or rename the object (winnt.h line ~3009). */ -export const DELETE = 0x00010000 -/** FILE_DELETE_CHILD: remove or rename a directory's children (winnt.h line ~5907). */ -export const FILE_DELETE_CHILD = 0x0040 -// The POC granted FILE_GENERIC_WRITE minus READ_CONTROL, which displays as -// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). The -// sandbox grant adds DELETE and FILE_DELETE_CHILD so confined -// delete/rename/git operations inside the granted trees pass the token's -// access check too; Write+DELETE displays as "Modify" in icacls. -// WRITE_DAC/WRITE_OWNER stay OUT deliberately — granting them would let the -// child take ownership or rewrite DACLs and escape the allowlist (the -// security boundary). -/** - * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and - * FILE_DELETE_CHILD — the write+delete access mask the capability-SID ACEs grant - * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are - * deliberately excluded: they would let the confined child take ownership or - * rewrite DACLs. - */ -export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156 - -/** - * FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE - * | 0x1FF): full file-object access. The mask of the ACE merged into the - * restricted token's DEFAULT DACL — the token holder must keep full access to - * every NEW object it creates (pipes included), and the ACE must name a - * restricting SID so the write pass-2 check passes at creation. - */ -export const FILE_ALL_ACCESS = 0x1F01FF - -// CreateRestrictedToken flags (winnt.h lines ~4284) -/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ -export const DISABLE_MAX_PRIVILEGE = 0x1 -/** LUA_TOKEN: produce a limited-user (filtered admin) token. */ -export const LUA_TOKEN = 0x4 -/** WRITE_RESTRICTED: intersect write access with the restricting SIDs' ACL grants — the sandbox's core mechanism. */ -export const WRITE_RESTRICTED = 0x8 - -// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) -/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */ -export const WinWorldSid = 1 - -// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) -/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ -export const TokenGroups = 2 -/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */ -export const TokenDefaultDacl = 6 - -// SECURITY_INFORMATION (winnt.h line ~4293) -/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ -export const DACL_SECURITY_INFORMATION = 0x00000004 - -// PROCESS access rights (winnt.h lines ~4364) -/** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */ +/** OpenProcess access required to query the current process token. */ export const PROCESS_QUERY_INFORMATION = 0x0400 - -// ---- accctrl.h ------------------------------------------------------------- - -// SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) -/** SE_FILE_OBJECT: the trustee path names a filesystem object. */ +/** Token right required by CreateProcessAsUserW. */ +export const TOKEN_ASSIGN_PRIMARY = 0x0001 +/** Token right required by DuplicateTokenEx. */ +export const TOKEN_DUPLICATE = 0x0002 +/** Token right required to read token information. */ +export const TOKEN_QUERY = 0x0008 +/** Token right required to replace the token default DACL. */ +export const TOKEN_ADJUST_DEFAULT = 0x0080 +/** Group attribute identifying the token logon SID. */ +export const SE_GROUP_LOGON_ID = 0xC0000000 +/** Standard-rights portion excluded from the write capability grant. */ +export const STANDARD_RIGHTS_WRITE = 0x00020000 +/** Generic file write access bits. */ +export const FILE_GENERIC_WRITE = 0x00120116 +/** Delete or rename an object. */ +export const DELETE = 0x00010000 +/** Delete or rename a directory child. */ +export const FILE_DELETE_CHILD = 0x0040 +/** Capability-SID access mask granting write, delete, and child deletion. */ +export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE +/** Full access used in the restricted token default DACL. */ +export const FILE_ALL_ACCESS = 0x1F01FF +/** CreateRestrictedToken flag that disables maximum privileges. */ +export const DISABLE_MAX_PRIVILEGE = 0x1 +/** CreateRestrictedToken limited-user flag. */ +export const LUA_TOKEN = 0x4 +/** Restrict write access to the listed restricting SIDs. */ +export const WRITE_RESTRICTED = 0x8 +/** WELL_KNOWN_SID_TYPE value for Everyone. */ +export const WinWorldSid = 1 +/** TOKEN_INFORMATION_CLASS value for token groups. */ +export const TokenGroups = 2 +/** TOKEN_INFORMATION_CLASS value for the token default DACL. */ +export const TokenDefaultDacl = 6 +/** SECURITY_INFORMATION flag selecting the DACL. */ +export const DACL_SECURITY_INFORMATION = 0x00000004 +/** SE_OBJECT_TYPE value for filesystem objects. */ export const SE_FILE_OBJECT = 1 - -// TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 -/** TRUSTEE_IS_UNKNOWN: TRUSTEE_TYPE unknown (TrusteeForm carries the shape). */ +/** TRUSTEE_TYPE value used when trustee classification is unknown. */ export const TRUSTEE_IS_UNKNOWN = 0 -/** TRUSTEE_IS_SID: TRUSTEE_FORM — Trustee.ptstrName is a SID pointer. */ +/** TRUSTEE_FORM value indicating a SID pointer. */ export const TRUSTEE_IS_SID = 0 -/** NO_MULTIPLE_TRUSTEE: Trustee.pMultipleTrustee is null. */ +/** Trustee record has no chained trustee. */ export const NO_MULTIPLE_TRUSTEE = 0 - -// ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) -/** GRANT_ACCESS: SetEntriesInAclW adds the entry as an allow ACE. */ +/** EXPLICIT_ACCESS mode that grants access. */ export const GRANT_ACCESS = 1 -/** REVOKE_ACCESS: SetEntriesInAclW removes the matching allow ACE. */ +/** EXPLICIT_ACCESS mode that revokes access. */ export const REVOKE_ACCESS = 4 - -// grfInheritance (accctrl.h lines ~137-142) -/** - * SUB_CONTAINERS_AND_OBJECTS_INHERIT: the ACE applies to the directory, its - * subdirectories, and files (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE). - */ -export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE - -// ---- winbase.h ------------------------------------------------------------- - -/** - * STARTF_USESTDHANDLES: STARTUPINFOW dwFlags — the child uses the hStd* - * handles, required because Node clears stdio inheritability at startup. - */ -export const STARTF_USESTDHANDLES = 0x00000100 -/** HANDLE_FLAG_INHERIT: SetHandleInformation flag re-enabling handle inheritance for the spawned child's stdio handles. */ -export const HANDLE_FLAG_INHERIT = 0x1 -/** INFINITE: never-timeout wait value. */ -export const INFINITE = 0xFFFFFFFF -/** MAX_PATH: legacy path length bound. */ +/** ACE inheritance flags for child containers and objects. */ +export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 +/** Legacy Win32 maximum path character count used by GetTempPathW. */ export const MAX_PATH = 260 -// winbase.h line ~410: the confined child starts suspended so the runner can -// assign it to the kill-on-close job before any of its code runs. -/** CREATE_SUSPENDED: create the child with its primary thread suspended until ResumeThread. */ -export const CREATE_SUSPENDED = 0x4 -// winbase.h lines ~497-499: GetStdHandle selectors. -/** STD_INPUT_HANDLE: GetStdHandle selector for the standard input. */ -export const STD_INPUT_HANDLE = -10 -/** STD_OUTPUT_HANDLE: GetStdHandle selector for the standard output. */ -export const STD_OUTPUT_HANDLE = -11 -/** STD_ERROR_HANDLE: GetStdHandle selector for the standard error. */ -export const STD_ERROR_HANDLE = -12 - -// FormatMessageW flags (winbase.h lines ~1446-1469) -/** FORMAT_MESSAGE_FROM_SYSTEM: format the message from the system message table. */ -export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 -/** FORMAT_MESSAGE_IGNORE_INSERTS: skip insert-sequence substitution. */ -export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 - -// ---- error codes ----------------------------------------------------------- - -/** ERROR_SUCCESS: the operation succeeded. */ +/** Successful Win32 status code. */ export const ERROR_SUCCESS = 0 -/** ERROR_INSUFFICIENT_BUFFER: a size-probe call succeeded but needs a larger buffer. */ -export const ERROR_INSUFFICIENT_BUFFER = 122 -/** ERROR_BROKEN_PIPE: the pipe's other end has closed. */ -export const ERROR_BROKEN_PIPE = 109 -/** ERROR_NO_DATA: the pipe is being closed. */ -export const ERROR_NO_DATA = 232 -/** ERROR_LOCK_VIOLATION: a byte-range lock conflicts with an existing lock (winerror.h line ~78). */ +/** Win32 error reported when an immediate byte-range lock cannot be obtained. */ export const ERROR_LOCK_VIOLATION = 33 - -// ---- lock files (fileapi.h / minwinbase.h / winnt.h) ----------------------- - -// CreateFileW dwDesiredAccess for the ACL lock files: plain read+write is -// enough to take byte-range locks. -/** GENERIC_READ: generic read access (winnt.h line ~3028). */ +/** Generic read access bit. */ export const GENERIC_READ = 0x80000000 -/** GENERIC_WRITE: generic write access (winnt.h line ~3029). */ +/** Generic write access bit. */ export const GENERIC_WRITE = 0x40000000 -// CreateFileW dwShareMode: the lock file is shared for read/write but NOT -// for delete — if a locked file could be deleted and recreated underneath the -// lock holder, two processes could hold "the same" lock on different files. -/** FILE_SHARE_READ: other opens may read (winnt.h line ~5949). */ +/** CreateFile share-read flag. */ export const FILE_SHARE_READ = 0x00000001 -/** FILE_SHARE_WRITE: other opens may write (winnt.h line ~5950). */ +/** CreateFile share-write flag. */ export const FILE_SHARE_WRITE = 0x00000002 -/** FILE_SHARE_DELETE: other opens may delete (winnt.h line ~5951) — deliberately NOT used for lock files. */ +/** CreateFile share-delete flag. */ export const FILE_SHARE_DELETE = 0x00000004 -/** OPEN_ALWAYS: create the lock file if absent, open it otherwise (fileapi.h line ~21). */ +/** CreateFile disposition that opens or creates the file. */ export const OPEN_ALWAYS = 4 -// LockFileEx dwFlags (minwinbase.h lines ~180-181, included by winbase.h). -/** LOCKFILE_EXCLUSIVE_LOCK: request an exclusive byte-range lock. */ +/** LockFileEx exclusive-lock flag. */ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 -/** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ +/** LockFileEx immediate-failure flag. */ export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 - -// ACE_HEADER.AceType (winnt.h lines ~3449-3463) -/** ACCESS_ALLOWED_ACE_TYPE: an access-allowed ACE granting the mask to the trustee. */ +/** ACE type for an allowed-access entry. */ export const ACCESS_ALLOWED_ACE_TYPE = 0 - -// SID structure (winnt.h line ~280 SID_IDENTIFIER_AUTHORITY; line ~286 -// #define SID_MAX_SUB_AUTHORITIES 15). -/** SID_MAX_SUB_AUTHORITIES: the most subauthorities a SID may carry. */ +/** Maximum SID sub-authority count. */ export const SID_MAX_SUB_AUTHORITIES = 15 - -// ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when -// reading a DACL are marked with this bit and are not part of the explicit -// DACL edits this module makes. -/** INHERITED_ACE: the ACE was inherited from the parent object, not stored explicitly. */ +/** ACE flag marking inherited entries. */ export const INHERITED_ACE = 0x10 - -// ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- - -// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last -// job handle closes — the orphan-child backstop for the runner design. -/** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */ -export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 -// JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. -/** JobObjectExtendedLimitInformation: JOBOBJECTINFOCLASS for the extended limit structure. */ -export const JobObjectExtendedLimitInformation = 9 -// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. -/** sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. */ -export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 -// LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION -// (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), -// verified by abi-probe. -/** - * LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION - * (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + - * PerJobUserTimeLimit@8), verified by abi-probe. - */ -export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 - -// ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- - -/** SECURITY_MAX_SID_SIZE: maximum SID byte size. */ +/** Maximum SID allocation size in bytes. */ export const SECURITY_MAX_SID_SIZE = 68 -/** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ +/** x64 SID_AND_ATTRIBUTES byte size. */ export const SID_AND_ATTRIBUTES_SIZE = 16 -/** TOKEN_GROUPS.Groups[] starts at offset 8 (GroupCount @0 + alignment). */ +/** x64 TOKEN_GROUPS offset of the first group entry. */ export const TOKEN_GROUPS_OFFSET = 8 -/** sizeof(EXPLICIT_ACCESS_W): perms@0 mode@4 inheritance@8 Trustee@16. */ +/** x64 EXPLICIT_ACCESS_W byte size. */ export const EXPLICIT_ACCESS_W_SIZE = 48 -/** Trustee offset inside EXPLICIT_ACCESS_W. */ +/** x64 offset of TRUSTEE_W inside EXPLICIT_ACCESS_W. */ export const TRUSTEE_W_OFFSET = 16 -/** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ +/** x64 offset of ptstrName inside TRUSTEE_W. */ export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 -/** sizeof(STARTUPINFOW), verified by abi-probe. */ -export const STARTUPINFOW_SIZE = 104 -/** sizeof(PROCESS_INFORMATION), verified by abi-probe. */ -export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index 005f522fda..fce0c3562a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -1,6 +1,6 @@ /** - * ACL failure-path tests with stub binding tables (the failure-paths.spec.ts - * pattern): every checked Win32 call in the lock, read-merge-write, and + * ACL failure-path tests with minimal stub binding tables: every checked + * Win32 call in the lock, read-merge-write, and * grant-skip sequence has a failing counterpart, and each failure closes the * handles it created before throwing. The exact-ACE skip and the DACL-walk * defenses are driven through crafted in-memory ACL/SID buffers. Pure @@ -9,13 +9,13 @@ */ import { tmpdir } from 'node:os' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts' import { allocBytes, ptrAddress } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' -import { Win32Error } from '../src/errors.ts' import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 903f56afc3..02dbbb82b4 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -1,18 +1,17 @@ /** - * FFI helper tests with stub binding tables (the failure-paths.spec.ts - * pattern): error formatting and temp-path decoding defenses, the + * Sandbox-specific FFI tests with stub binding tables: temp-path decoding, * last-error throwers' detail fallback, pointer decode NULL handling, and * the bounded SID comparison's early exits. Pure stubs — no real Win32 * calls, so these run on every platform; the real-FFI round-trip lives in * acl.spec.ts and probe.spec.ts (win32 only). */ +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { Win32Error } from '../src/errors.ts' import { - allocBytes, decodePtr, decodePtrAt, errorText, getTempPath, + allocBytes, decodePtr, decodePtrAt, getTempPath, isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32, } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' @@ -48,18 +47,6 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, return sid } -describe('errorText', () => { - it('decodes the formatted UTF-16 message and trims it', () => { - const { api } = formatApi() - expect(errorText(api, 5)).toBe('access denied') - }) - - it('returns an empty string when FormatMessageW formats nothing', () => { - const api = { formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings - expect(errorText(api, 5)).toBe('') - }) -}) - describe('getTempPath', () => { it('decodes the NUL-terminated temp path GetTempPathW wrote', () => { const api = { @@ -83,6 +70,11 @@ describe('getTempPath', () => { expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('GetTempPathW') }) + + it('rejects a required length larger than the fixed buffer', () => { + const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings + expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) + }) }) describe('throwLastError and throwWin32', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index fe803025b9..e22a46060c 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -1,6 +1,6 @@ /** - * AclWriteGrant failure-path tests with stub binding tables (the - * failure-paths.spec.ts pattern): create fails closed on SID-parse failure, + * AclWriteGrant failure-path tests with minimal stub binding tables: create + * fails closed on SID-parse failure, * dispose aggregates revocation and SID-free failures into an * AggregateError. Pure stubs — no real Win32 calls, so these run on every * platform; the real-FFI round-trip lives in grant.spec.ts (win32 only). diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index c171d30084..adc1aaa8a1 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -11,12 +11,13 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' +import { ERROR_BROKEN_PIPE } from '@deepseek-ai/dsh-win32-process/src/abi.ts' +import { PROCESS_INFORMATION } from '@deepseek-ai/dsh-win32-process/src/ffi.ts' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' -import { Win32Error } from '../src/errors.ts' import { AclSandbox } from '../src/index.ts' import * as abi from '../src/win32-abi.ts' @@ -149,7 +150,7 @@ function happyStubs(): HappyStubs { const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) const closeHandle = vi.fn(() => 1) - const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF + const getLastError = vi.fn(() => ERROR_BROKEN_PIPE) // the drains' clean EOF const formatMessageW = vi.fn(() => 0) const api = { @@ -394,6 +395,65 @@ describe('AclSandbox spawn', () => { jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) }) + + it('inherit spawn caches one failing settlement and closes the Job once', async () => { + const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs + api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + const jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr + await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) + await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) + expect(closeHandle.mock.calls.filter(([handle]) => handle === jobHandle)).toHaveLength(1) + }) + + it('inherit spawn aggregates wait and Job-close failures', async () => { + const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs + api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-1', mode: 'workspace-write' }) + await sandbox.init() + let jobHandle = 0n + closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr + await expect(child.wait()).rejects.toMatchObject({ + errors: [ + expect.objectContaining({ api: 'WaitForSingleObject' }), + expect.objectContaining({ api: 'CloseHandle' }), + ], + }) + }) + + it('pipe spawn reports a wait failure after successful drains', async () => { + const { api } = state.stubs as HappyStubs + api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-2', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) + }) + + it('pipe spawn still closes the process after a drain failure', async () => { + const { api } = state.stubs as HappyStubs + api.getLastError = vi.fn(() => 5) + const waitForSingleObject = vi.fn(() => 0) + api.waitForSingleObject = waitForSingleObject + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toMatchObject({ + errors: [ + expect.objectContaining({ api: 'PeekNamedPipe' }), + expect.objectContaining({ api: 'PeekNamedPipe' }), + ], + }) + expect(waitForSingleObject).toHaveBeenCalledOnce() + }) }) describe('AclSandbox dispose', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts deleted file mode 100644 index 5af00fb9cb..0000000000 --- a/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * quoteArg unit tests plus a round-trip through the REAL CommandLineToArgvW - * parser (shell32.dll, shellapi.h line ~867: - * `LPWSTR *CommandLineToArgvW(LPCWSTR lpCmdLine, int *pNumArgs)`) on win32. - * - * CommandLineToArgvW applies the documented backslash rule (2n backslashes - * before a quote produce n backslashes and toggle quoting; 2n+1 produce n - * backslashes and a literal quote) to every token EXCEPT the first — the - * first token is parsed with backslashes literal and quotes toggling - * (verified empirically on this machine, Windows 11 build 26200). The - * round-trip therefore prepends a plain program token, exactly like - * buildCommandLine's real callers do, so the arguments under test land on - * the rule-applying tokens. - * - * Reading argv from CommandLineToArgvW: koffi cannot decode the returned - * LPWSTR* contents directly (the pointed-to strings are not koffi-registered - * references), so each string is copied with lstrcpynW (winbase.h line - * ~1500) into a Node Buffer and read as UTF-16LE; lengths come from - * lstrlenW (winbase.h line ~1506); the argv block is freed with LocalFree - * (winbase.h line ~1127) — CommandLineToArgvW's documented contract. - */ - -import { describe, expect, it } from 'vitest' - -import { buildCommandLine, quoteArg } from '../src/spawn.ts' - -const isWin32 = process.platform === 'win32' - -/** - * Table cases: input argv entry → the exact command-line fragment quoteArg - * must produce. Trailing-backslash inputs are the regression: the closing - * quote must be preceded by DOUBLED backslashes, or the parser reads them as - * escaping the closing quote. - */ -const cases: Array<[input: string, quoted: string]> = [ - ['', '""'], - ['a', 'a'], - ['a b', '"a b"'], - ['a"b', '"a\\"b"'], - ['a\\b', 'a\\b'], - ['a b\\', '"a b\\\\"'], - ['a b\\\\', '"a b\\\\\\\\"'], - ['a b\\\\\\', '"a b\\\\\\\\\\\\"'], - ['a\\\\"b', '"a\\\\\\\\\\"b"'], -] - -describe('quoteArg', () => { - it.each(cases)('quotes %j as %j', (input, quoted) => { - expect(quoteArg(input)).toBe(quoted) - }) -}) - -describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => { - it('parses quoteArg+join back to the exact original argv', async () => { - const { default: koffi } = await import('koffi') - const PVOID = koffi.pointer('void') - const shell32 = koffi.load('shell32.dll') - const kernel32 = koffi.load('kernel32.dll') - const commandLineToArgvW = shell32.func('__stdcall', 'CommandLineToArgvW', PVOID, ['str16', koffi.pointer('int')]) - const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int']) - const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID]) - const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID]) - - const parse = (commandLine: string): string[] => { - const countSlot = koffi.alloc('int', 1) as unknown - const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown - try { - if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL') - const count = koffi.decode(countSlot, 0, 'int') as number - const table = Buffer.from(koffi.view(argvBlock, count * 8)) - const parsed: string[] = [] - for (let index = 0; index < count; index++) { - const stringAddress = table.readBigUInt64LE(index * 8) - const copied = Buffer.alloc(2048) - lstrcpynW(copied, stringAddress, copied.length / 2) - const length = lstrlenW(copied) as number - parsed.push(copied.subarray(0, length * 2).toString('utf16le')) - } - return parsed - } finally { - localFree(argvBlock) - } - } - - const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a b\\\\\\', 'a\\\\"b'] - expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv]) - }) -}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index b3559ee781..6149d75277 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -1,6 +1,6 @@ /** - * Restricted-token failure-path tests with stub binding tables (the - * failure-paths.spec.ts pattern): every checked Win32 call in the token + * Restricted-token failure-path tests with minimal stub binding tables: every + * checked Win32 call in the token * pipeline — open, logon-SID scan, well-known SID creation, default-DACL * merge, restricted-token creation — has a failing counterpart, and each * failure closes or frees what it created before throwing. Pure stubs — no @@ -8,12 +8,12 @@ * lives in acl.spec.ts (win32 only). */ +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { allocBytes, isNullPtr } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' -import { Win32Error } from '../src/errors.ts' import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant, } from '../src/token.ts' diff --git a/packages/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json index 1d7a70a5b1..97f5530dd8 100644 --- a/packages/sandbox/sandbox-windows-acl/tsconfig.json +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../subprocess/win32-process" } ] } diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index a74afe9d80..55a3d9c9d8 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -1,6 +1,3 @@ -// ABI probe: prints sizeof/offsetof/enum values from the actual MinGW Windows -// headers on this machine. These numbers are the source of truth for the -// koffi FFI definitions in the Node.js port. #include #include #include @@ -11,185 +8,67 @@ int wmain() { - P(sizeof(void*)); - P(sizeof(HANDLE)); - P(sizeof(DWORD)); - P(sizeof(WORD)); - P(sizeof(BOOL)); + P(sizeof(TRUSTEE_W)); + P(offsetof(TRUSTEE_W, ptstrName)); + P(sizeof(EXPLICIT_ACCESS_W)); + P(offsetof(EXPLICIT_ACCESS_W, Trustee)); + P(sizeof(SID_AND_ATTRIBUTES)); + P(offsetof(SID_AND_ATTRIBUTES, Attributes)); + P(sizeof(TOKEN_GROUPS)); + P(offsetof(TOKEN_GROUPS, Groups)); + P(SECURITY_MAX_SID_SIZE); + P(SID_MAX_SUB_AUTHORITIES); + P(TOKEN_ASSIGN_PRIMARY); + P(TOKEN_DUPLICATE); + P(TOKEN_QUERY); + P(TOKEN_ADJUST_DEFAULT); + P(SE_GROUP_LOGON_ID); + P(FILE_GENERIC_WRITE); + P(STANDARD_RIGHTS_WRITE); + P(DELETE); + P(FILE_DELETE_CHILD); + P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE)); + P(FILE_SHARE_READ); + P(FILE_SHARE_WRITE); + P(FILE_SHARE_DELETE); + P(GENERIC_READ); + P(GENERIC_WRITE); + P(OPEN_ALWAYS); + P(LOCKFILE_EXCLUSIVE_LOCK); + P(LOCKFILE_FAIL_IMMEDIATELY); + P(ERROR_LOCK_VIOLATION); + P(INHERITED_ACE); + P(DISABLE_MAX_PRIVILEGE); + P(LUA_TOKEN); + P(WRITE_RESTRICTED); + P((int)WinWorldSid); + P((int)TokenGroups); + P((int)SE_FILE_OBJECT); + P(DACL_SECURITY_INFORMATION); + P((int)TRUSTEE_IS_UNKNOWN); + P((int)TRUSTEE_IS_SID); + P((int)GRANT_ACCESS); + P((int)REVOKE_ACCESS); + P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(MAX_PATH); + P(ERROR_SUCCESS); - P(sizeof(STARTUPINFOW)); - P(offsetof(STARTUPINFOW, cb)); - P(offsetof(STARTUPINFOW, lpReserved)); - P(offsetof(STARTUPINFOW, lpDesktop)); - P(offsetof(STARTUPINFOW, lpTitle)); - P(offsetof(STARTUPINFOW, dwX)); - P(offsetof(STARTUPINFOW, dwY)); - P(offsetof(STARTUPINFOW, dwXSize)); - P(offsetof(STARTUPINFOW, dwYSize)); - P(offsetof(STARTUPINFOW, dwXCountChars)); - P(offsetof(STARTUPINFOW, dwYCountChars)); - P(offsetof(STARTUPINFOW, dwFillAttribute)); - P(offsetof(STARTUPINFOW, dwFlags)); - P(offsetof(STARTUPINFOW, wShowWindow)); - P(offsetof(STARTUPINFOW, cbReserved2)); - P(offsetof(STARTUPINFOW, lpReserved2)); - P(offsetof(STARTUPINFOW, hStdInput)); - P(offsetof(STARTUPINFOW, hStdOutput)); - P(offsetof(STARTUPINFOW, hStdError)); - - P(sizeof(PROCESS_INFORMATION)); - P(offsetof(PROCESS_INFORMATION, hProcess)); - P(offsetof(PROCESS_INFORMATION, hThread)); - P(offsetof(PROCESS_INFORMATION, dwProcessId)); - P(offsetof(PROCESS_INFORMATION, dwThreadId)); - - P(sizeof(SECURITY_ATTRIBUTES)); - P(offsetof(SECURITY_ATTRIBUTES, nLength)); - P(offsetof(SECURITY_ATTRIBUTES, lpSecurityDescriptor)); - P(offsetof(SECURITY_ATTRIBUTES, bInheritHandle)); - - P(sizeof(TRUSTEE_W)); - P(offsetof(TRUSTEE_W, pMultipleTrustee)); - P(offsetof(TRUSTEE_W, MultipleTrusteeOperation)); - P(offsetof(TRUSTEE_W, TrusteeForm)); - P(offsetof(TRUSTEE_W, TrusteeType)); - P(offsetof(TRUSTEE_W, ptstrName)); - - P(sizeof(EXPLICIT_ACCESS_W)); - P(offsetof(EXPLICIT_ACCESS_W, grfAccessPermissions)); - P(offsetof(EXPLICIT_ACCESS_W, grfAccessMode)); - P(offsetof(EXPLICIT_ACCESS_W, grfInheritance)); - P(offsetof(EXPLICIT_ACCESS_W, Trustee)); - - P(sizeof(SID_AND_ATTRIBUTES)); - P(offsetof(SID_AND_ATTRIBUTES, Sid)); - P(offsetof(SID_AND_ATTRIBUTES, Attributes)); - - P(sizeof(TOKEN_GROUPS)); - P(offsetof(TOKEN_GROUPS, GroupCount)); - P(offsetof(TOKEN_GROUPS, Groups)); - - P(sizeof(TOKEN_MANDATORY_LABEL)); - - P(sizeof(SID)); - P(SECURITY_MAX_SID_SIZE); - P(SID_MAX_SUB_AUTHORITIES); - P(SID_REVISION); - - P(TOKEN_ASSIGN_PRIMARY); - P(TOKEN_DUPLICATE); - P(TOKEN_QUERY); - P(TOKEN_ADJUST_DEFAULT); - - P(SE_GROUP_LOGON_ID); - P(SE_GROUP_INTEGRITY); - P(SE_GROUP_INTEGRITY_ENABLED); - - P(FILE_GENERIC_WRITE); - P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); - P(STANDARD_RIGHTS_WRITE); - P(DELETE); - P(FILE_DELETE_CHILD); - P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE)); - - P(FILE_SHARE_READ); - P(FILE_SHARE_WRITE); - P(FILE_SHARE_DELETE); - P(GENERIC_READ); - P(GENERIC_WRITE); - P(OPEN_ALWAYS); - P(LOCKFILE_EXCLUSIVE_LOCK); - P(LOCKFILE_FAIL_IMMEDIATELY); - P(ERROR_LOCK_VIOLATION); - P(INHERITED_ACE); - - P(DISABLE_MAX_PRIVILEGE); - P(SANDBOX_INERT); - P(LUA_TOKEN); - P(WRITE_RESTRICTED); - - P((int)WinWorldSid); - P((int)WinLocalLogonSid); - P((int)WinConsoleLogonSid); - - P((int)TokenUser); - P((int)TokenGroups); - P((int)TokenIntegrityLevel); - - P((int)SE_FILE_OBJECT); - P(DACL_SECURITY_INFORMATION); - - P((int)TRUSTEE_IS_UNKNOWN); - P((int)TRUSTEE_IS_SID); - P((int)NOT_USED_ACCESS); - P((int)GRANT_ACCESS); - P((int)REVOKE_ACCESS); - P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); - P(OBJECT_INHERIT_ACE); - P(CONTAINER_INHERIT_ACE); - - P(CREATE_SUSPENDED); - P(CREATE_NO_WINDOW); - P(DETACHED_PROCESS); - P(CREATE_NEW_CONSOLE); - P(STARTF_USESTDHANDLES); - P(HANDLE_FLAG_INHERIT); - P(INFINITE); - - P(LMEM_FIXED); - P(LMEM_ZEROINIT); - P(LPTR); - - P(FORMAT_MESSAGE_ALLOCATE_BUFFER); - P(FORMAT_MESSAGE_FROM_SYSTEM); - P(FORMAT_MESSAGE_IGNORE_INSERTS); - P(MAX_PATH); - - P(ERROR_SUCCESS); - P(ERROR_INSUFFICIENT_BUFFER); - P(ERROR_NO_MORE_ITEMS); - P(ERROR_INVALID_PARAMETER); - P(ERROR_INVALID_SID); - P(ERROR_NONE_MAPPED); - P(ERROR_BROKEN_PIPE); - - // Job object (runner kill-on-close hardening) - P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); - P(sizeof(JOBOBJECT_BASIC_LIMIT_INFORMATION)); - P(sizeof(IO_COUNTERS)); - P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation)); - P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); - P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, ProcessMemoryLimit)); - P((int)JobObjectExtendedLimitInformation); - P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); - - // static assertions for the values the koffi module will hardcode - static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); - static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(sizeof(SECURITY_ATTRIBUTES) == 24, "SECURITY_ATTRIBUTES size"); - static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); - static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); - static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); - static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); - static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); - static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); - static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); - static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "poc grant mask"); - static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights"); - static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask"); - static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes"); - static_assert(OPEN_ALWAYS == 4, "open always"); - static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags"); - static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation"); - static_assert(INHERITED_ACE == 0x10, "inherited ace flag"); - static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); - static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); - static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); - static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); - static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); - static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); - static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); - static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); - printf("\nstatic_asserts passed\n"); - return 0; + static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); + static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); + static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); + static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); + static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); + static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); + static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights"); + static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask"); + static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes"); + static_assert(OPEN_ALWAYS == 4, "open always"); + static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags"); + static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation"); + static_assert(INHERITED_ACE == 0x10, "inherited ace flag"); + static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); + static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); + printf("\nstatic_asserts passed\n"); + return 0; } diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index b294c97415..7cdeda55c3 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 packages/subprocess/README.md -README.md: 72a30775f45a8140935a013c63e9427e9cbd94d6 -README.zh.md: 35c801a1f4a5e8ad161b8c4ff57ad6ffa07e699d +README.md: ba74f0d2ed2251c3527259b571663abf5bf740a2 +README.zh.md: fefc13d49b94ddba2e697d3481b4b991531540a8 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 72a30775f4..ba74f0d2ed 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,6 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, Job assignment, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 35c801a1f4..fefc13d49b 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,6 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、Job 指派、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml new file mode 100644 index 0000000000..6e68e09233 --- /dev/null +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md +README.md: a18b1b8167e3ea76d61f022f4aa3ea827546d93f +README.zh.md: 262300f5da48aeed4fe7c05d970d498147921c49 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md new file mode 100644 index 0000000000..a18b1b8167 --- /dev/null +++ b/packages/subprocess/win32-process/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-win32-process + +English | [中文](README.zh.md) + +Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the repository's one Koffi binding table for reusable restricted-process, stdio, and Job Object operations; it is not a Cordis service and does not choose sandbox policy or public child behavior. + +## Behavior + +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. +- **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, restores the parent handle flags, and resumes the child. Creation, assignment, or resume failure closes every owned resource; assignment failure terminates the still-suspended child before releasing its process and thread handles. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. + +The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. + +## Model Experience + +### Process primitives + +#### What the model sees + +Nothing directly. The package exposes `Win32ProcessBindings` and process primitives to the sandbox, which owns all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema. + +#### Token effect + +None directly. Consumers decide whether process output enters a tool result or later model request. + +#### KV Cache effect + +The package contributes no stable request prefix, so it does not invalidate model KV caches. + +## Known Limitations and Deferred Work + +- **Windows-only native loading** — importing the generic types is portable, but resolving the binding table loads Windows DLLs and fails on other hosts. Cross-platform tests inject a binding table instead of loading native APIs. +- **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. +- **Inherited environment only** — process creation passes a null environment block. Callers that need environment changes must establish them before invoking the primitive or use their own runner process. +- **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. +- **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md new file mode 100644 index 0000000000..262300f5da --- /dev/null +++ b/packages/subprocess/win32-process/README.zh.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-win32-process + +[English](README.md) | 中文 + +供 Windows ACL 沙箱消费的底层 Win32 进程库。它唯一拥有仓库中可复用 restricted-process、stdio 与 Job Object 操作的 Koffi 绑定表;它不是 Cordis 服务,也不决定沙箱策略或公共 child 行为。 + +## Behavior + +- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 +- **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,将其指派给 Job,恢复父进程句柄标志,再 resume child。创建、指派或 resume 失败都会关闭全部已拥有资源;指派失败会先终止仍 suspended 的 child,再释放其 process 与 thread handles。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 + +Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 + +## Model Experience + +### 进程原语 + +#### 模型看到什么 + +没有直接内容。本包向 sandbox 提供 `Win32ProcessBindings` 与进程原语;sandbox 拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。 + +#### Token 影响 + +没有直接影响。消费方决定进程输出是否进入工具结果或后续模型请求。 + +#### KV Cache effect + +本包不贡献稳定请求前缀,因此不会使模型 KV Cache 失效。 + +## Known Limitations and Deferred Work + +- **仅在 Windows 原生加载** — 导入通用类型可跨平台进行,但解析绑定表会加载 Windows DLL,并在其他宿主失败。跨平台测试注入绑定表,不加载原生 API。 +- **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 +- **只继承环境** — 进程创建传入空环境块。需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 +- **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 +- **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json new file mode 100644 index 0000000000..7d6257d692 --- /dev/null +++ b/packages/subprocess/win32-process/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-win32-process", + "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subprocess/win32-process" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "koffi": "^3.1.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts new file mode 100644 index 0000000000..fbdda9059f --- /dev/null +++ b/packages/subprocess/win32-process/src/abi.ts @@ -0,0 +1,38 @@ +/** Generic Win32 process, stdio, and Job Object constants verified on x64. */ + +/** STARTUPINFOW uses the standard input, output, and error handles. */ +export const STARTF_USESTDHANDLES = 0x00000100 +/** HandleInformation flag that permits child inheritance. */ +export const HANDLE_FLAG_INHERIT = 0x1 +/** Infinite WaitForSingleObject timeout. */ +export const INFINITE = 0xFFFFFFFF +/** CreateProcess flag that prevents user code from running before resume. */ +export const CREATE_SUSPENDED = 0x4 +/** GetStdHandle selector for standard input. */ +export const STD_INPUT_HANDLE = -10 +/** GetStdHandle selector for standard output. */ +export const STD_OUTPUT_HANDLE = -11 +/** GetStdHandle selector for standard error. */ +export const STD_ERROR_HANDLE = -12 +/** FormatMessage reads the operating system message table. */ +export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +/** FormatMessage leaves insertion placeholders uninterpreted. */ +export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 +/** Win32 code reporting a caller-provided buffer is too small. */ +export const ERROR_INSUFFICIENT_BUFFER = 122 +/** Win32 code reporting that the other pipe end closed. */ +export const ERROR_BROKEN_PIPE = 109 +/** Win32 code reporting that a pipe has no remaining data. */ +export const ERROR_NO_DATA = 232 +/** Job limit that terminates every member when the final Job handle closes. */ +export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +/** SetInformationJobObject class for JOBOBJECT_EXTENDED_LIMIT_INFORMATION. */ +export const JobObjectExtendedLimitInformation = 9 +/** x64 JOBOBJECT_EXTENDED_LIMIT_INFORMATION byte size. */ +export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 +/** Byte offset of BasicLimitInformation.LimitFlags in the extended Job record. */ +export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 +/** x64 STARTUPINFOW byte size verified by the native probe. */ +export const STARTUPINFOW_SIZE = 104 +/** x64 PROCESS_INFORMATION byte size verified by the native probe. */ +export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts new file mode 100644 index 0000000000..84bd2a7ac2 --- /dev/null +++ b/packages/subprocess/win32-process/src/errors.ts @@ -0,0 +1,14 @@ +/** Win32 call failure with the exact API name and error code. */ +export class Win32Error extends Error { + /** Win32 function whose checked result failed. */ + readonly api: string + /** Exact GetLastError value captured before cleanup changed it. */ + readonly win32Code: number + + constructor(api: string, win32Code: number, detail?: string) { + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + this.name = 'Win32Error' + this.api = api + this.win32Code = win32Code + } +} diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts new file mode 100644 index 0000000000..38b3e32200 --- /dev/null +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -0,0 +1,319 @@ +/** Lazy Koffi bindings for generic Win32 process, stdio, and Job operations. */ + +import koffi from 'koffi' +import * as abi from './abi.ts' +import { Win32Error } from './errors.ts' + +declare const nativePtr: unique symbol +/** Koffi native pointer branded against accidental numeric use. */ +export type NativePtr = bigint & { readonly [nativePtr]: true } + +type Ptr = ReturnType +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +/** Loaded Win32 libraries and the shared stdcall binder used by process extensions. */ +export interface Win32BindingContext { + /** Kernel process, handle, pipe, and Job APIs. */ + readonly kernel32: ReturnType + /** Token and security APIs. */ + readonly advapi32: ReturnType + /** Bind one stdcall function from a loaded Win32 library. */ + readonly bind: ( + library: ReturnType, + name: string, + result: Ptr | string, + args: Array, + ) => unknown +} + +/** + * Return whether a Koffi pointer represents NULL. + * @param value - pointer value returned by Koffi or a Win32 call. + * @returns true for null, undefined, or address zero. + */ +export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { + return value === null || value === undefined || (value as bigint) === 0n +} + +/** STARTUPINFOW fields used by inherited or piped stdio launches. */ +export interface StartupInfoInput { + cb: number + dwFlags: number + hStdInput: NativePtr + hStdOutput: NativePtr + hStdError: NativePtr +} + +/** Decoded PROCESS_INFORMATION result. */ +export interface ProcessInfoOutput { + hProcess: NativePtr | null + hThread: NativePtr | null + dwProcessId: number + dwThreadId: number +} + +/** Generic Win32 calls consumed by restricted-token sandbox process operations. */ +export interface Win32ProcessBindings { + closeHandle(handle: NativePtr): number + getLastError(): number + formatMessageW( + flags: number, + source: null, + messageId: number, + languageId: number, + buffer: Buffer, + size: number, + args: null, + ): number + createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + setHandleInformation(handle: NativePtr, mask: number, flags: number): number + createProcessAsUserW( + token: NativePtr, + applicationName: string | null, + commandLine: string, + processAttributes: null, + threadAttributes: null, + inheritHandles: number, + creationFlags: number, + environment: null, + currentDirectory: string | null, + startupInfo: NativePtr, + processInfo: NativePtr, + ): number + readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number + peekNamedPipe( + pipe: NativePtr, + buffer: null, + size: number, + bytesRead: NativePtr | null, + totalAvail: NativePtr, + leftThisMessage: NativePtr | null, + ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number + getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number + resumeThread(thread: NativePtr): number + createJobObjectW(attributes: null, name: null): NativePtr + setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + terminateProcess(process: NativePtr, exitCode: number): number + getStdHandle(stdHandle: number): NativePtr +} + +/** Koffi STARTUPINFOW layout. */ +export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', { + cb: 'uint32', + lpReserved: 'str16', + lpDesktop: 'str16', + lpTitle: 'str16', + dwX: 'uint32', + dwY: 'uint32', + dwXSize: 'uint32', + dwYSize: 'uint32', + dwXCountChars: 'uint32', + dwYCountChars: 'uint32', + dwFillAttribute: 'uint32', + dwFlags: 'uint32', + wShowWindow: 'uint16', + cbReserved2: 'uint16', + lpReserved2: koffi.pointer('uint8'), + hStdInput: PVOID, + hStdOutput: PVOID, + hStdError: PVOID, +}) + +/** Koffi PROCESS_INFORMATION layout. */ +export const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', { + hProcess: PVOID, + hThread: PVOID, + dwProcessId: 'uint32', + dwThreadId: 'uint32', +}) + +/* v8 ignore start -- ABI guards are pinned by native header probes. */ +if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { + throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, expected ${abi.STARTUPINFOW_SIZE}`) +} +if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { + throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, expected ${abi.PROCESS_INFORMATION_SIZE}`) +} +/* v8 ignore stop */ + +/** + * Allocate a pointer-sized out-parameter slot. + * @returns allocated native slot. + */ +export function allocPtrSlot(): NativePtr { + return koffi.alloc(PVOID, 1) as NativePtr +} + +/** + * Allocate a uint32 out-parameter slot. + * @returns allocated native slot. + */ +export function allocUint32(): NativePtr { + return koffi.alloc('uint32', 1) as NativePtr +} + +/** + * Decode a pointer out-parameter. + * @param slot - pointer-sized slot filled by Win32. + * @returns decoded pointer, or null for address zero. + */ +export function decodePtr(slot: NativePtr): NativePtr | null { + const value = koffi.decode(slot, PVOID) as NativePtr | null + return isNullPtr(value) ? null : value +} + +/** + * Decode a uint32 out-parameter. + * @param slot - uint32 slot filled by Win32. + * @returns decoded unsigned value. + */ +export function decodeUint32(slot: NativePtr): number { + return koffi.decode(slot, 'uint32') as number +} + +/** + * Allocate a zeroed STARTUPINFOW. + * @returns allocated struct pointer. + */ +export function allocStartupInfo(): NativePtr { + return koffi.alloc(STARTUPINFOW, 1) as NativePtr +} + +/** + * Encode the stdio-bearing STARTUPINFOW fields. + * @param startupInfo - allocated STARTUPINFOW pointer. + * @param fields - fields required for inherited stdio. + */ +export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { + koffi.encode(startupInfo, STARTUPINFOW, fields) +} + +/** + * Allocate a zeroed PROCESS_INFORMATION. + * @returns allocated struct pointer. + */ +export function allocProcessInfo(): NativePtr { + return koffi.alloc(PROCESS_INFORMATION, 1) as NativePtr +} + +/** + * Decode PROCESS_INFORMATION. + * @param processInfo - struct pointer filled by CreateProcess. + * @returns process/thread handles and ids. + */ +export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { + return koffi.decode(processInfo, PROCESS_INFORMATION) as ProcessInfoOutput +} + +let cachedContext: Win32BindingContext | undefined +let cached: Win32ProcessBindings | undefined + +/* v8 ignore start -- exercised by native Windows ABI and sandbox jobs. */ +function bindingContext(): Win32BindingContext { + if (cachedContext !== undefined) return cachedContext + const kernel32 = koffi.load('kernel32.dll') + const advapi32 = koffi.load('advapi32.dll') + const bind = ( + lib: ReturnType, + name: string, + result: Ptr | string, + args: Array, + ): unknown => lib.func('__stdcall', name, result, args) + cachedContext = { kernel32, advapi32, bind } + return cachedContext +} + +function bindings(): Win32ProcessBindings { + if (cached !== undefined) return cached + const { kernel32, advapi32, bind } = bindingContext() + cached = { + closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), + getLastError: bind(kernel32, 'GetLastError', 'uint32', []), + formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', [ + 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, + ]), + createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), + createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ + PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', + koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + ]), + readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), + peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ + PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), + ]), + waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), + getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), + createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), + setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), + getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), + } as unknown as Win32ProcessBindings + return cached +} + +/** + * Extend the shared process table with caller-owned Win32 API families. + * @param create - binds only the caller-specific operations from the shared libraries. + * @returns generic process bindings combined with the caller-specific operations. + */ +export function extendWin32ProcessBindings( + create: (context: Win32BindingContext) => Extension, +): Win32ProcessBindings & Extension { + return { ...bindings(), ...create(bindingContext()) } +} +/* v8 ignore stop */ + +/** + * Format a Win32 error code through FormatMessageW. + * @param api - active binding table. + * @param win32Code - captured GetLastError value. + * @returns trimmed system message, or an empty string when unavailable. + */ +export function errorText(api: Win32ProcessBindings, win32Code: number): string { + const buffer = Buffer.alloc(1024) + const length = api.formatMessageW( + abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, + null, + win32Code, + 0, + buffer, + buffer.length / 2, + null, + ) + return length === 0 ? '' : buffer.subarray(0, length * 2).toString('utf16le').trim() +} + +/** + * Throw the current GetLastError value. + * @param api - active binding table. + * @param name - failing Win32 operation. + * @param detail - optional operation context. + * @returns never; always throws Win32Error. + */ +export function throwLastError(api: Win32ProcessBindings, name: string, detail?: string): never { + const win32Code = api.getLastError() + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} + +/** + * Throw an explicitly captured Win32 error code. + * @param api - active binding table. + * @param name - failing Win32 operation. + * @param win32Code - error captured before cleanup. + * @param detail - optional operation context. + * @returns never; always throws Win32Error. + */ +export function throwWin32( + api: Win32ProcessBindings, + name: string, + win32Code: number, + detail?: string, +): never { + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts new file mode 100644 index 0000000000..fa7f2dd992 --- /dev/null +++ b/packages/subprocess/win32-process/src/index.ts @@ -0,0 +1,29 @@ +/** Low-level Win32 process, stdio, and Job Object primitives used by the Windows ACL sandbox. */ + +export { ERROR_INSUFFICIENT_BUFFER } from './abi.ts' +export * from './errors.ts' +export { + allocPtrSlot, + allocUint32, + decodePtr, + decodeUint32, + extendWin32ProcessBindings, + isNullPtr, + throwLastError, + throwWin32, +} from './ffi.ts' +export type { + NativePtr, + Win32ProcessBindings, +} from './ffi.ts' +export { + closeHandleChecked, + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from './process.ts' +export type { + SpawnedJobProcess, + SpawnedPipedProcess, +} from './process.ts' diff --git a/packages/subprocess/win32-process/src/invariant.ts b/packages/subprocess/win32-process/src/invariant.ts new file mode 100644 index 0000000000..bd29124923 --- /dev/null +++ b/packages/subprocess/win32-process/src/invariant.ts @@ -0,0 +1,17 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-win32-process`. */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-win32-process' + +export const name = 'win32-process-invariant' +export const inject = ['invariants'] + +/** No runtime invariant: operations own only call-local native handles. */ +const install: InvariantInstaller = () => {} + +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts new file mode 100644 index 0000000000..f62195413f --- /dev/null +++ b/packages/subprocess/win32-process/src/process.ts @@ -0,0 +1,431 @@ +/** Typed Win32 process operations over the shared binding table. */ + +import koffi from 'koffi' +import * as abi from './abi.ts' +import { + allocProcessInfo, + allocPtrSlot, + allocStartupInfo, + allocUint32, + decodeProcessInfo, + decodePtr, + decodeUint32, + encodeStartupInfo, + isNullPtr, + throwLastError, + throwWin32, +} from './ffi.ts' +import type { NativePtr, Win32ProcessBindings } from './ffi.ts' + +/** + * Quote one argument according to CommandLineToArgvW parsing. + * @param argument - one argv entry. + * @returns bare or quoted command-line segment. + */ +export function quoteArg(argument: string): string { + if (argument === '') return '""' + if (!/[\s"]/u.test(argument)) return argument + let quoted = '"' + for (let index = 0; index < argument.length; index++) { + let backslashes = 0 + while (index < argument.length && argument.charAt(index) === '\\') { + backslashes += 1 + index += 1 + } + if (index === argument.length) { + quoted += '\\'.repeat(backslashes * 2) + } else if (argument.charAt(index) === '"') { + quoted += '\\'.repeat(backslashes * 2 + 1) + '"' + } else { + quoted += '\\'.repeat(backslashes) + argument.charAt(index) + } + } + return quoted + '"' +} + +/** + * Build the mutable command line accepted by CreateProcessAsUserW. + * @param program - executable argv entry. + * @param args - remaining argv entries. + * @returns joined Win32 command line. + */ +export function buildCommandLine(program: string, args: readonly string[]): string { + return [program, ...args].map(quoteArg).join(' ') +} + +/** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ +export interface RestrictedProcessSpawnOptions { + /** Executable argv entry passed through CreateProcessAsUserW. */ + command: string + /** Arguments excluding the executable. */ + args: readonly string[] + /** Existing child working directory. */ + cwd: string + /** Restricted primary token supplied by sandbox policy. */ + token: NativePtr +} + +/** Piped child resources whose process and read handles remain caller-owned. */ +export interface SpawnedPipedProcess { + /** Direct child process id. */ + pid: number + /** Process handle closed by waitForProcessExit. */ + process: NativePtr + /** Stdout pipe read end closed by drainPipe. */ + stdoutRead: NativePtr + /** Stderr pipe read end closed by drainPipe. */ + stderrRead: NativePtr +} + +/** Suspended-created child assigned to one caller-owned kill-on-close Job before resume. */ +export interface SpawnedJobProcess { + /** Direct child process id. */ + pid: number + /** Process handle closed by waitForProcessExit. */ + process: NativePtr + /** Job handle closed by the lifecycle owner. */ + job: NativePtr +} + +interface PipePair { + read: NativePtr + write: NativePtr +} + +function freeNative(pointer: NativePtr | undefined): void { + if (pointer !== undefined) koffi.free(pointer) +} + +function closeBestEffort(api: Win32ProcessBindings, handle: NativePtr | null | undefined): void { + if (!isNullPtr(handle)) api.closeHandle(handle) +} + +function createPipe(api: Win32ProcessBindings, owned: Set): PipePair { + const readSlot = allocPtrSlot() + let writeSlot: NativePtr | undefined + try { + writeSlot = allocPtrSlot() + if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') + const read = decodePtr(readSlot) + const write = decodePtr(writeSlot) + if (read === null || write === null) { + closeBestEffort(api, read) + closeBestEffort(api, write) + throwLastError(api, 'CreatePipe', 'null pipe handle') + } + owned.add(read) + owned.add(write) + return { read, write } + } finally { + freeNative(writeSlot) + koffi.free(readSlot) + } +} + +function closeOwned(api: Win32ProcessBindings, owned: Set, handle: NativePtr): void { + /* v8 ignore next -- each successfully decoded pipe end is uniquely owned. */ + if (!owned.delete(handle)) return + api.closeHandle(handle) +} + +function closeAllOwned(api: Win32ProcessBindings, owned: Set): void { + for (const handle of owned) api.closeHandle(handle) + owned.clear() +} + +function createRestrictedProcess( + api: Win32ProcessBindings, + options: RestrictedProcessSpawnOptions, + commandLine: string, + creationFlags: number, + startupInfo: NativePtr, + processInfo: NativePtr, +): number { + return api.createProcessAsUserW( + options.token, + null, + commandLine, + null, + null, + 1, + creationFlags, + null, + options.cwd, + startupInfo, + processInfo, + ) +} + +/** + * Spawn a process with anonymous-pipe stdout/stderr and immediate stdin EOF. + * @param api - active binding table. + * @param options - command, cwd, args, and restricted primary token. + * @returns caller-owned process and pipe read handles. + */ +export function spawnPipedProcess( + api: Win32ProcessBindings, + options: RestrictedProcessSpawnOptions, +): SpawnedPipedProcess { + const owned = new Set() + let startupInfo: NativePtr | undefined + let processInfo: NativePtr | undefined + try { + const stdIn = createPipe(api, owned) + const stdOut = createPipe(api, owned) + const stdErr = createPipe(api, owned) + for (const [handle, label] of [ + [stdIn.read, 'stdin read end'], + [stdOut.write, 'stdout write end'], + [stdErr.write, 'stderr write end'], + ] as const) { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', label) + } + } + startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn.read, + hStdOutput: stdOut.write, + hStdError: stdErr.write, + }) + processInfo = allocProcessInfo() + const created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + 0, + startupInfo, + processInfo, + ) + if (created === 0) { + const win32Code = api.getLastError() + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } + const info = decodeProcessInfo(processInfo) + if (info.hProcess === null || info.hThread === null) { + if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + closeOwned(api, owned, stdIn.read) + closeOwned(api, owned, stdIn.write) + closeOwned(api, owned, stdOut.write) + closeOwned(api, owned, stdErr.write) + closeBestEffort(api, info.hThread) + owned.delete(stdOut.read) + owned.delete(stdErr.read) + return { + pid: info.dwProcessId, + process: info.hProcess, + stdoutRead: stdOut.read, + stderrRead: stdErr.read, + } + } catch (error) { + closeAllOwned(api, owned) + throw error + } finally { + freeNative(processInfo) + freeNative(startupInfo) + } +} + +/** + * Drain one anonymous pipe until the writer closes it. + * @param api - active binding table. + * @param handle - caller-owned pipe read end. + * @returns complete bytes read before EOF; the handle is always closed. + */ +export async function drainPipe(api: Win32ProcessBindings, handle: NativePtr): Promise { + const chunks: Buffer[] = [] + let countSlot: NativePtr | undefined + try { + countSlot = allocUint32() + for (;;) { + const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) + if (peeked === 0) { + const win32Code = api.getLastError() + if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break + throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) + } + const available = decodeUint32(countSlot) + if (available > 0) { + const chunk = Buffer.alloc(available) + if (api.readFile(handle, chunk, chunk.length, countSlot, null) === 0) { + throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) + } + chunks.push(chunk.subarray(0, decodeUint32(countSlot))) + } + await new Promise(resolve => setTimeout(resolve, 1)) + } + return Buffer.concat(chunks) + } finally { + freeNative(countSlot) + api.closeHandle(handle) + } +} + +/** + * Wait for a process and always close its handle. + * @param api - active binding table. + * @param process - caller-owned process handle. + * @returns direct process exit code. + */ +export function waitForProcessExit(api: Win32ProcessBindings, process: NativePtr): number { + let exitCodeSlot: NativePtr | undefined + try { + if (api.waitForSingleObject(process, abi.INFINITE) === 0xFFFFFFFF) { + throwLastError(api, 'WaitForSingleObject') + } + exitCodeSlot = allocUint32() + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + return decodeUint32(exitCodeSlot) + } finally { + freeNative(exitCodeSlot) + api.closeHandle(process) + } +} + +function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { + const job = api.createJobObjectW(null, null) + if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') + const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) + information.writeUInt32LE( + abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET, + ) + if (api.setInformationJobObject( + job, + abi.JobObjectExtendedLimitInformation, + information, + information.length, + ) === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'SetInformationJobObject', win32Code) + } + return job +} + +/** + * Spawn suspended inside a kill-on-close Job, then resume. + * @param api - active binding table. + * @param options - command, cwd, args, and restricted primary token. + * @returns caller-owned process and Job handles after successful resume. + */ +export function spawnInheritedJobProcess( + api: Win32ProcessBindings, + options: RestrictedProcessSpawnOptions, +): SpawnedJobProcess { + const job = createKillOnCloseJob(api) + const getStdHandle = (selector: number, label: string): NativePtr => { + const handle = api.getStdHandle(selector) + if (!isNullPtr(handle)) return handle + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) + } + const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') + const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') + const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') + const enabled: NativePtr[] = [] + let startupInfo: NativePtr | undefined + let processInfo: NativePtr | undefined + let created = 0 + let createFailureCode = 0 + try { + for (const [handle, label] of [ + [stdIn, 'stdin'], + [stdOut, 'stdout'], + [stdErr, 'stderr'], + ] as const) { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) + } + enabled.push(handle) + } + startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn, + hStdOutput: stdOut, + hStdError: stdErr, + }) + processInfo = allocProcessInfo() + created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + ) + if (created === 0) createFailureCode = api.getLastError() + } catch (error) { + freeNative(processInfo) + freeNative(startupInfo) + api.closeHandle(job) + throw error + } finally { + for (const handle of enabled) api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } + if (created === 0) { + freeNative(processInfo) + freeNative(startupInfo) + api.closeHandle(job) + throwWin32( + api, + 'CreateProcessAsUserW', + createFailureCode, + `command: ${options.command}, cwd: ${options.cwd}`, + ) + } + let info: ReturnType + try { + info = decodeProcessInfo(processInfo) + } finally { + freeNative(processInfo) + freeNative(startupInfo) + } + if (info.hProcess === null || info.hThread === null) { + if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + if (api.assignProcessToJobObject(job, info.hProcess) === 0) { + const win32Code = api.getLastError() + api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { + const win32Code = api.getLastError() + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } + closeBestEffort(api, info.hThread) + return { pid: info.dwProcessId, process: info.hProcess, job } +} + +/** + * Close a handle and surface a failure without losing its operation label. + * @param api - active binding table. + * @param handle - caller-owned handle to close. + * @param detail - lifecycle label included in a failure. + */ +export function closeHandleChecked( + api: Win32ProcessBindings, + handle: NativePtr, + detail: string, +): void { + if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', detail) +} diff --git a/packages/subprocess/win32-process/tests/ffi.spec.ts b/packages/subprocess/win32-process/tests/ffi.spec.ts new file mode 100644 index 0000000000..dfe27537f3 --- /dev/null +++ b/packages/subprocess/win32-process/tests/ffi.spec.ts @@ -0,0 +1,45 @@ +import koffi from 'koffi' +import { describe, expect, it, vi } from 'vitest' +import { + Win32Error, + allocPtrSlot, + decodePtr, + isNullPtr, + throwLastError, +} from '../src/index.ts' +import { PROCESS_INFORMATION_SIZE, STARTUPINFOW_SIZE } from '../src/abi.ts' +import { PROCESS_INFORMATION, STARTUPINFOW, errorText } from '../src/ffi.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' + +describe('shared Win32 process ABI', () => { + it('matches the verified x64 structure sizes', () => { + expect(STARTUPINFOW.size).toBe(STARTUPINFOW_SIZE) + expect(PROCESS_INFORMATION.size).toBe(PROCESS_INFORMATION_SIZE) + }) + + it('handles NULL pointer out-parameters', () => { + const slot = allocPtrSlot() + expect(decodePtr(slot)).toBeNull() + expect(isNullPtr(0n as NativePtr)).toBe(true) + expect(isNullPtr(1n as NativePtr)).toBe(false) + }) + + it('formats and throws the exact Win32 error', () => { + const api = { + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn((_flags, _source, _id, _language, buffer: Buffer) => { + buffer.write('access denied', 'utf16le') + return 'access denied'.length + }), + } as unknown as Win32ProcessBindings + expect(errorText(api, 5)).toBe('access denied') + expect(() => throwLastError(api, 'Probe')).toThrow(Win32Error) + expect(new Win32Error('CloseHandle', 6).message).toBe('CloseHandle failed (Win32 6)') + }) + + it('decodes a pointer stored by Koffi', () => { + const slot = allocPtrSlot() + koffi.encode(slot, koffi.pointer('void'), 42n) + expect(decodePtr(slot)).toBe(42n) + }) +}) diff --git a/packages/subprocess/win32-process/tests/invariant.spec.ts b/packages/subprocess/win32-process/tests/invariant.spec.ts new file mode 100644 index 0000000000..82078672d3 --- /dev/null +++ b/packages/subprocess/win32-process/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest' +import { apply, inject, name } from '../src/invariant.ts' + +describe('win32-process invariant companion', () => { + it('registers the package-owned empty invariant', async () => { + const dispose = vi.fn() + const register = vi.fn((_packageName: string, _installer: () => void) => dispose) + const ctx = { invariants: { register } } as never + await expect(apply(ctx)).resolves.toBe(dispose) + expect(name).toBe('win32-process-invariant') + expect(inject).toEqual(['invariants']) + expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-win32-process', expect.any(Function)) + const installer = register.mock.calls[0]![1] + installer() + }) +}) diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts new file mode 100644 index 0000000000..714af104b2 --- /dev/null +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -0,0 +1,145 @@ +import koffi from 'koffi' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from '../src/index.ts' +import * as ffi from '../src/ffi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts' + +vi.mock('../src/ffi.ts', { spy: true }) + +const PVOID = koffi.pointer('void') + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('spawnInheritedJobProcess allocation cleanup', () => { + it('frees startup info when process-info allocation throws', () => { + const api = { + createJobObjectW: vi.fn(() => 50n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + setHandleInformation: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + vi.mocked(ffi.allocProcessInfo).mockImplementationOnce(() => { throw new Error('process-info allocation failed') }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\', + token: 70n as NativePtr, + })).toThrow('process-info allocation failed') + expect(free).toHaveBeenCalledOnce() + }) + + it('frees process info after a successful inherited spawn', () => { + const api = { + createJobObjectW: vi.fn(() => 50n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + expect(spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\', + token: 70n as NativePtr, + })).toEqual({ pid: 1234, process: 60n, job: 50n }) + expect(free).toHaveBeenCalledTimes(2) + }) +}) + +describe('shared process allocation cleanup', () => { + it('frees pipe slots and process structs after a successful piped spawn', () => { + let nextHandle = 10n + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, nextHandle++) + koffi.encode(writeSlot, PVOID, nextHandle++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }), + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + expect(spawnPipedProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\', + token: 70n as NativePtr, + })).toMatchObject({ pid: 1234, process: 60n }) + expect(free).toHaveBeenCalledTimes(8) + }) + + it('reuses one drain count slot and frees it at EOF', async () => { + let peeks = 0 + const api = { + peekNamedPipe: vi.fn((_pipe, _buffer, _size, _read, totalAvail: NativePtr) => { + peeks += 1 + if (peeks > 1) return 0 + koffi.encode(totalAvail, 'uint32', 1) + return 1 + }), + readFile: vi.fn((_file, buffer: Buffer, _count, readSlot: NativePtr) => { + buffer[0] = 0x61 + koffi.encode(readSlot, 'uint32', 1) + return 1 + }), + getLastError: vi.fn(() => 109), + closeHandle: vi.fn(() => 1), + } as unknown as Win32ProcessBindings + const alloc = vi.spyOn(koffi, 'alloc') + const free = vi.spyOn(koffi, 'free') + await expect(drainPipe(api, 70n as NativePtr)).resolves.toEqual(Buffer.from('a')) + expect(alloc).toHaveBeenCalledOnce() + expect(free).toHaveBeenCalledOnce() + }) + + it('frees the exit-code slot after reading a process result', () => { + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn((_process, exitCode: NativePtr) => { + koffi.encode(exitCode, 'uint32', 42) + return 1 + }), + closeHandle: vi.fn(() => 1), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + expect(waitForProcessExit(api, 60n as NativePtr)).toBe(42) + expect(free).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts similarity index 71% rename from packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts rename to packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index a6bea87998..9335fccbf0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -1,23 +1,28 @@ /** * Failure-path unit tests with minimal stub binding tables: the spawn * helpers must close every handle they created before throwing, and - * getTempPath must refuse to decode a buffer GetTempPathW never wrote. + * every generic process failure remains owned by the shared package. * Pure stubs — no real Win32 calls, so these run on every platform. */ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' -import type { NativePtr, Win32Bindings } from '../src/ffi.ts' -import { Win32Error } from '../src/errors.ts' -import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts' -import * as abi from '../src/win32-abi.ts' +import { + Win32Error, + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from '../src/index.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' +import * as abi from '../src/abi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' const PVOID = koffi.pointer('void') /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ -function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { +function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType } { const closed: bigint[] = [] let next = 1n const closeHandle = vi.fn((handle: NativePtr) => { @@ -35,18 +40,23 @@ function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return { api, closed, closeHandle } } /** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */ -function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { +function resumeFailureApi(): { + api: Win32ProcessBindings + closed: bigint[] + closeHandle: ReturnType +} { const closed: bigint[] = [] let std = 50n const closeHandle = vi.fn((handle: NativePtr) => { closed.push(handle) return 1 }) + const resumeThread = vi.fn(() => 0xFFFFFFFF) const api = { createJobObjectW: vi.fn(() => 100n), setInformationJobObject: vi.fn(() => 1), @@ -60,11 +70,11 @@ function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle return 1 }), assignProcessToJobObject: vi.fn(() => 1), - resumeThread: vi.fn(() => 0xFFFFFFFF), + resumeThread, getLastError: vi.fn(() => 5), closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return { api, closed, closeHandle } } @@ -72,11 +82,11 @@ describe('spawn failure paths close their handles', () => { // A dummy token value; the stubbed spawn never reads it. const token = 1n as NativePtr - it('spawnSandboxed closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => { + it('closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => { const { api, closed, closeHandle } = pipeFailureApi() let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -87,11 +97,11 @@ describe('spawn failure paths close their handles', () => { expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n]) }) - it('spawnSandboxedInherited closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { + it('closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { const { api, closed, closeHandle } = resumeFailureApi() let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -104,43 +114,11 @@ describe('spawn failure paths close their handles', () => { expect(closed).toEqual([201n, 200n, 100n]) }) - it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => { - // The child is created suspended and is NOT in the kill-on-close job when - // the assignment fails: closing the job cannot kill it, so the failure - // branch must TerminateProcess first or every failure strands a hanging - // orphan forever. - const { api: baseApi, closeHandle } = resumeFailureApi() - type JobFailureApi = Win32Bindings & { - assignProcessToJobObject: ReturnType - terminateProcess: ReturnType - } - const api = baseApi as JobFailureApi - api.assignProcessToJobObject = vi.fn(() => 0) - api.terminateProcess = vi.fn(() => 1) - let caught: unknown - try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(Win32Error) - expect((caught as Win32Error).api).toBe('AssignProcessToJobObject') - expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1) - // thread, process, job — and the child is already dead before they close. - expect(closeHandle).toHaveBeenCalledTimes(3) - }) -}) - -describe('getTempPath buffer defense', () => { - it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => { - const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer - expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) - }) }) /** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */ -function pipeOkApi(overrides: Partial = {}): { - api: Win32Bindings +function pipeOkApi(overrides: Partial = {}): { + api: Win32ProcessBindings closed: bigint[] closeHandle: ReturnType } { @@ -168,18 +146,22 @@ function pipeOkApi(overrides: Partial = {}): { closeHandle, formatMessageW: vi.fn(() => 0), ...overrides, - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return { api, closed, closeHandle } } describe('spawn pipe failures close their handles', () => { const token = 1n as NativePtr - it('spawnSandboxed reports a CreatePipe failure', () => { - const api = { createPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + it('reports a CreatePipe failure', () => { + const api = { + createPipe: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -187,11 +169,15 @@ describe('spawn pipe failures close their handles', () => { expect((caught as Win32Error).api).toBe('CreatePipe') }) - it('spawnSandboxed reports a NULL pipe handle after CreatePipe succeeds', () => { - const api = { createPipe: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + it('reports a NULL pipe handle after CreatePipe succeeds', () => { + const api = { + createPipe: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -199,11 +185,11 @@ describe('spawn pipe failures close their handles', () => { expect((caught as Win32Error).api).toBe('CreatePipe') }) - it('spawnSandboxed reports a SetHandleInformation failure', () => { + it('reports a SetHandleInformation failure', () => { const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) }) let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -211,7 +197,7 @@ describe('spawn pipe failures close their handles', () => { expect((caught as Win32Error).api).toBe('SetHandleInformation') }) - it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => { + it('rejects NULL process/thread handles after a successful spawn', () => { const { api } = pipeOkApi({ createProcessAsUserW: vi.fn(( _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, @@ -221,17 +207,17 @@ describe('spawn pipe failures close their handles', () => { return 1 }), }) - expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + expect(() => spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) .toThrow(/null process\/thread handles/u) }) }) -describe('spawnSandboxedInherited failure paths', () => { +describe('spawnInheritedJobProcess failure paths', () => { const token = 1n as NativePtr /** The stub the inherited-happy path needs; overrides flip one call per test. */ - function inheritedApi(overrides: Partial = {}): { - api: Win32Bindings + function inheritedApi(overrides: Partial = {}): { + api: Win32ProcessBindings closed: bigint[] closeHandle: ReturnType } { @@ -259,7 +245,7 @@ describe('spawnSandboxedInherited failure paths', () => { closeHandle, formatMessageW: vi.fn(() => 0), ...overrides, - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return { api, closed, closeHandle } } @@ -267,7 +253,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -280,7 +266,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -292,7 +278,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -311,16 +297,35 @@ describe('spawnSandboxedInherited failure paths', () => { return 1 }), }) - expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) .toThrow(/null process\/thread handles/u) expect(closeHandle).toHaveBeenCalledWith(100n) }) + it('terminates the suspended child when Job assignment fails', () => { + const terminateProcess = vi.fn(() => 1) + const { api, closeHandle } = inheritedApi({ + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, + }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 }) + expect(terminateProcess).toHaveBeenCalledWith(200n, 1) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + it('closes the job and reports when SetInformationJobObject fails', () => { const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -333,7 +338,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -343,7 +348,7 @@ describe('spawnSandboxedInherited failure paths', () => { it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => { const { api, closeHandle } = inheritedApi() - const spawned = spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + const spawned = spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) expect(spawned.pid).toBe(1234) expect(spawned.process).toBe(200n) expect(spawned.job).toBe(100n) @@ -362,7 +367,7 @@ describe('drainPipe', () => { getLastError: vi.fn(() => abi.ERROR_NO_DATA), closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return drainPipe(api, 30n as NativePtr).then((buffer) => { expect(buffer.length).toBe(0) expect(closeHandle).toHaveBeenCalledWith(30n) @@ -370,13 +375,15 @@ describe('drainPipe', () => { }) it('reports a PeekNamedPipe failure that is not a clean EOF', () => { + const closeHandle = vi.fn(() => 1) const api = { peekNamedPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), - closeHandle: vi.fn(() => 1), + closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + .then(() => { expect(closeHandle).toHaveBeenCalledWith(30n) }) }) it('reports a ReadFile failure after data was reported available', () => { @@ -389,7 +396,7 @@ describe('drainPipe', () => { getLastError: vi.fn(() => 5), closeHandle: vi.fn(() => 1), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' }) }) @@ -410,31 +417,37 @@ describe('drainPipe', () => { getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE), closeHandle: vi.fn(() => 1), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return drainPipe(api, 30n as NativePtr).then((buffer) => { expect(buffer.toString('utf8')).toBe('ab') }) }) }) -describe('waitForExit', () => { +describe('waitForProcessExit', () => { it('reports a WaitForSingleObject failure', () => { + const closeHandle = vi.fn(() => 1) const api = { waitForSingleObject: vi.fn(() => 0xFFFFFFFF), getLastError: vi.fn(() => 5), + closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings - expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + } as unknown as Win32ProcessBindings + expect(() => waitForProcessExit(api, 200n as NativePtr)).toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(200n) }) it('reports a GetExitCodeProcess failure', () => { + const closeHandle = vi.fn(() => 1) const api = { waitForSingleObject: vi.fn(() => 0), getExitCodeProcess: vi.fn(() => 0), getLastError: vi.fn(() => 5), + closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings - expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + } as unknown as Win32ProcessBindings + expect(() => waitForProcessExit(api, 200n as NativePtr)).toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(200n) }) it('returns the exit code and closes the process handle', () => { @@ -447,8 +460,8 @@ describe('waitForExit', () => { }), closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings - expect(waitForExit(api, 200n as NativePtr)).toBe(42) + } as unknown as Win32ProcessBindings + expect(waitForProcessExit(api, 200n as NativePtr)).toBe(42) expect(closeHandle).toHaveBeenCalledWith(200n) }) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts new file mode 100644 index 0000000000..ecee195b2c --- /dev/null +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -0,0 +1,243 @@ +import koffi from 'koffi' +import { describe, expect, it, vi } from 'vitest' +import { + Win32Error, + closeHandleChecked, + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, +} from '../src/index.ts' +import { CREATE_SUSPENDED } from '../src/abi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' + +const PVOID = koffi.pointer('void') + +function inheritedApi(overrides: Partial = {}): { + api: Win32ProcessBindings + events: string[] + createProcessAsUserW: ReturnType + assignProcessToJobObject: ReturnType +} { + const events: string[] = [] + const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = + overrides.createProcessAsUserW + ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + events.push('create') + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }) + const createProcessAsUserW = vi.fn(createProcessAsUserWImpl) + const assignProcessToJobObject = vi.fn(() => { events.push('assign'); return 1 }) + const api = { + createJobObjectW: vi.fn(() => 50n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + setHandleInformation: vi.fn((_handle: NativePtr, _mask: number, flags: number) => { + events.push(flags === 0 ? 'restore' : 'inherit') + return 1 + }), + assignProcessToJobObject, + resumeThread: vi.fn(() => { events.push('resume'); return 1 }), + terminateProcess: vi.fn(() => 1), + closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + ...overrides, + createProcessAsUserW, + } as unknown as Win32ProcessBindings + return { + api, + events, + createProcessAsUserW, + assignProcessToJobObject, + } +} + +describe('spawnInheritedJobProcess', () => { + const token = 70n as NativePtr + + it('attaches a restricted suspended child to the Job inside CreateProcessAsUserW', () => { + const { + api, + events, + createProcessAsUserW, + assignProcessToJobObject, + } = inheritedApi() + const child = spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: ['/c', 'exit', '0'], + cwd: 'C:\\work', + token, + }) + expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) + expect(events.indexOf('assign')).toBeGreaterThan(events.indexOf('create')) + expect(events.indexOf('resume')).toBeGreaterThan(events.indexOf('create')) + expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n) + expect(createProcessAsUserW).toHaveBeenCalledWith( + token, + null, + 'cmd.exe /c exit 0', + null, + null, + 1, + CREATE_SUSPENDED, + null, + 'C:\\work', + expect.anything(), + expect.anything(), + ) + }) + + it('restores already-enabled stdio and closes the Job when inheritance setup fails', () => { + let calls = 0 + const closeHandle = vi.fn(() => 1) + const setHandleInformation = vi.fn((_handle: NativePtr, _mask: number, flags: number) => { + if (flags === 0) return 1 + calls += 1 + return calls === 2 ? 0 : 1 + }) + const { api } = inheritedApi({ closeHandle, setHandleInformation }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(setHandleInformation).toHaveBeenCalledWith(expect.anything(), 1, 0) + expect(closeHandle).toHaveBeenCalledWith(50n) + }) + + it('captures a GetStdHandle error before Job cleanup changes last-error', () => { + let lastError = 123 + const { api } = inheritedApi({ + getStdHandle: vi.fn(() => 0n as NativePtr), + getLastError: vi.fn(() => lastError), + closeHandle: vi.fn(() => { lastError = 999; return 1 }), + }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'cmd.exe', args: [], cwd: 'C:\\work', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'GetStdHandle', win32Code: 123 }) + }) + + it('captures a CreateProcess error before inheritance restoration changes last-error', () => { + let lastError = 87 + const { api } = inheritedApi({ + createProcessAsUserW: vi.fn(() => 0), + getLastError: vi.fn(() => lastError), + setHandleInformation: vi.fn((_handle, _mask, flags) => { + if (flags === 0) lastError = 999 + return 1 + }), + closeHandle: vi.fn(() => { lastError = 998; return 1 }), + }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'cmd.exe', args: [], cwd: 'C:\\work', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 }) + }) + + it('terminates a restricted child when CreateProcessAsUserW returns a null thread handle', () => { + const terminateProcess = vi.fn(() => 1) + const { api } = inheritedApi({ + terminateProcess, + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 0n, + dwProcessId: 1234, + dwThreadId: 0, + }) + return 1 + }), + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow('null process/thread handles') + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + }) +}) + +describe('wait and pipe cleanup', () => { + const token = 70n as NativePtr + + it('waits when a pipe is temporarily empty before observing EOF', async () => { + const closeHandle = vi.fn(() => 1) + let peeks = 0 + const api = { + peekNamedPipe: vi.fn((_handle, _buffer, _size, _read, available) => { + peeks += 1 + if (peeks === 1) { + koffi.encode(available, 'uint32', 0) + return 1 + } + return 0 + }), + getLastError: vi.fn(() => 109), + closeHandle, + } as unknown as Win32ProcessBindings + await expect(drainPipe(api, 80n as NativePtr)).resolves.toEqual(Buffer.alloc(0)) + expect(closeHandle).toHaveBeenCalledWith(80n) + }) + + it('checks caller-owned handle closure', () => { + const closeHandle = vi.fn(() => 1) + const api = { closeHandle } as unknown as Win32ProcessBindings + expect(() => { closeHandleChecked(api, 80n as NativePtr, 'sandbox Job') }).not.toThrow() + expect(closeHandle).toHaveBeenCalledWith(80n) + + const failing = { + closeHandle: vi.fn(() => 0), + getLastError: vi.fn(() => 6), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + expect(() => { closeHandleChecked(failing, 81n as NativePtr, 'sandbox Job') }).toThrow(Win32Error) + }) + + it('terminates a piped child when CreateProcess returns a null thread handle', () => { + let nextPipe = 10n + const terminateProcess = vi.fn(() => 1) + const closeHandle = vi.fn(() => 1) + const api = { + createPipe: vi.fn((readSlot, writeSlot) => { + koffi.encode(readSlot, PVOID, nextPipe++) + koffi.encode(writeSlot, PVOID, nextPipe++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 0n, + dwProcessId: 1234, + dwThreadId: 0, + }) + return 1 + }), + terminateProcess, + closeHandle, + } as unknown as Win32ProcessBindings + expect(() => spawnPipedProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow('null process/thread handles') + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + }) +}) diff --git a/packages/subprocess/win32-process/tests/quote.spec.ts b/packages/subprocess/win32-process/tests/quote.spec.ts new file mode 100644 index 0000000000..63dcc7bc77 --- /dev/null +++ b/packages/subprocess/win32-process/tests/quote.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { buildCommandLine, quoteArg } from '../src/process.ts' + +const isWin32 = process.platform === 'win32' + +const cases: Array<[string, string]> = [ + ['', '""'], + ['a', 'a'], + ['a b', '"a b"'], + ['a"b', '"a\\"b"'], + ['a\\b', 'a\\b'], + ['a b\\', '"a b\\\\"'], + ['a b\\\\', '"a b\\\\\\\\"'], + ['a\\\\"b', '"a\\\\\\\\\\"b"'], +] + +describe('quoteArg', () => { + it.each(cases)('quotes %j as %j', (input, expected) => { + expect(quoteArg(input)).toBe(expected) + }) + + it('builds one CreateProcess command line without shell interpretation', () => { + expect(buildCommandLine('C:\\Program Files\\tool.exe', ['a b', 'c'])).toBe( + '"C:\\Program Files\\tool.exe" "a b" c', + ) + }) +}) + +describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => { + it('parses the shared command line back to the original argv', async () => { + const { default: koffi } = await import('koffi') + const PVOID = koffi.pointer('void') + const shell32 = koffi.load('shell32.dll') + const kernel32 = koffi.load('kernel32.dll') + const commandLineToArgvW = shell32.func( + '__stdcall', + 'CommandLineToArgvW', + PVOID, + ['str16', koffi.pointer('int')], + ) + const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int']) + const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID]) + const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID]) + const parse = (commandLine: string): string[] => { + const countSlot = koffi.alloc('int', 1) as unknown + const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown + try { + if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL') + const count = koffi.decode(countSlot, 0, 'int') as number + const table = Buffer.from(koffi.view(argvBlock, count * 8)) + return Array.from({ length: count }, (_, index) => { + const stringAddress = table.readBigUInt64LE(index * 8) + const copied = Buffer.alloc(2048) + lstrcpynW(copied, stringAddress, copied.length / 2) + const length = lstrlenW(copied) as number + return copied.subarray(0, length * 2).toString('utf16le') + }) + } finally { + localFree(argvBlock) + } + } + const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a\\\\"b'] + expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv]) + }) +}) diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json new file mode 100644 index 0000000000..2f159cfc48 --- /dev/null +++ b/packages/subprocess/win32-process/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp new file mode 100644 index 0000000000..347fafdd3c --- /dev/null +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -0,0 +1,48 @@ +#include +#include +#include + +#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr)) + +int wmain() +{ + P(sizeof(void*)); + P(sizeof(HANDLE)); + P(sizeof(STARTUPINFOW)); + P(offsetof(STARTUPINFOW, dwFlags)); + P(offsetof(STARTUPINFOW, hStdInput)); + P(offsetof(STARTUPINFOW, hStdOutput)); + P(offsetof(STARTUPINFOW, hStdError)); + P(sizeof(PROCESS_INFORMATION)); + P(offsetof(PROCESS_INFORMATION, hProcess)); + P(offsetof(PROCESS_INFORMATION, hThread)); + P(offsetof(PROCESS_INFORMATION, dwProcessId)); + P(CREATE_SUSPENDED); + P(STARTF_USESTDHANDLES); + P(HANDLE_FLAG_INHERIT); + P(INFINITE); + P(STD_INPUT_HANDLE); + P(STD_OUTPUT_HANDLE); + P(STD_ERROR_HANDLE); + P(FORMAT_MESSAGE_FROM_SYSTEM); + P(FORMAT_MESSAGE_IGNORE_INSERTS); + P(ERROR_INSUFFICIENT_BUFFER); + P(ERROR_BROKEN_PIPE); + P(ERROR_NO_DATA); + P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); + P((int)JobObjectExtendedLimitInformation); + P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + + static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); + static_assert(CREATE_SUSPENDED == 0x4, "create suspended"); + static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); + static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); + static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); + static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); + static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); + static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); + printf("\nstatic_asserts passed\n"); + return 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96bfb5d0de..6f489c33e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5799,6 +5799,9 @@ importers: packages/sandbox/sandbox-windows-acl: dependencies: + '@deepseek-ai/dsh-win32-process': + specifier: workspace:^ + version: link:../../subprocess/win32-process koffi: specifier: ^3.1.0 version: 3.1.1 @@ -7613,6 +7616,19 @@ importers: specifier: workspace:^ version: link:../../util/timeout + packages/subprocess/win32-process: + dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/terminal/terminal: devDependencies: '@deepseek-ai/cordis': diff --git a/tsconfig.host.json b/tsconfig.host.json index c95fcd91e0..938ad3372e 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -191,6 +191,7 @@ { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/subprocess/subprocess" }, { "path": "./packages/subprocess/subprocess-local" }, + { "path": "./packages/subprocess/win32-process" }, { "path": "./packages/e2b/e2b" }, { "path": "./packages/e2b/subprocess-e2b" }, { "path": "./packages/shell/shell" }, From e18564de03869987532c7ccab13500c2090b2758 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:11:59 +0800 Subject: [PATCH 007/248] chore(sandbox): align inherited wait lint --- .../sandbox/sandbox-windows-acl/src/index.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 40d0d47a1d..e0bb8b2323 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -358,24 +358,27 @@ export class AclSandbox { let settlement: Promise | undefined return { pid: native.pid, - // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. - wait: () => (settlement ??= (async () => { - const failures: unknown[] = [] - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - try { - closeHandleChecked(api, native.job, 'kill-on-close job') - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') - return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } - })()), + wait: () => { + // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. + settlement ??= (async () => { + const failures: unknown[] = [] + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + try { + closeHandleChecked(api, native.job, 'kill-on-close job') + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + })() + return settlement + }, } } From f1fd304dffb4f4495713d1e7bf329b274d5f9200 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:14:07 +0800 Subject: [PATCH 008/248] fix(sandbox): memoize inherited settlement promise --- .../sandbox/sandbox-windows-acl/src/index.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index e0bb8b2323..10fe48be45 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -358,27 +358,23 @@ export class AclSandbox { let settlement: Promise | undefined return { pid: native.pid, - wait: () => { - // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. - settlement ??= (async () => { - const failures: unknown[] = [] - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - try { - closeHandleChecked(api, native.job, 'kill-on-close job') - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') - return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } - })() - return settlement - }, + wait: () => (settlement ??= new Promise((resolveResult) => { + const failures: unknown[] = [] + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + try { + closeHandleChecked(api, native.job, 'kill-on-close job') + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') + resolveResult({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode }) + })), } } From ab494bfdcaa11f839037c63a9ca43a32fdad7459 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:39:31 +0800 Subject: [PATCH 009/248] refactor(win32-process): narrow PR1 native surface --- .../sandbox-windows-acl/tests/ffi.spec.ts | 63 ++----------------- packages/subprocess/win32-process/src/ffi.ts | 2 +- 2 files changed, 6 insertions(+), 59 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 02dbbb82b4..7c046ae87f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -1,7 +1,7 @@ /** * Sandbox-specific FFI tests with stub binding tables: temp-path decoding, - * last-error throwers' detail fallback, pointer decode NULL handling, and - * the bounded SID comparison's early exits. Pure stubs — no real Win32 + * invalid-handle checks, pointer-at-offset decoding, and the bounded SID + * comparison's early exits. Pure stubs — no real Win32 * calls, so these run on every platform; the real-FFI round-trip lives in * acl.spec.ts and probe.spec.ts (win32 only). */ @@ -11,14 +11,12 @@ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { - allocBytes, decodePtr, decodePtrAt, getTempPath, - isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32, + allocBytes, decodePtrAt, getTempPath, + isInvalidHandle, sameSidAt, } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' -const PVOID = koffi.pointer('void') - /** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */ function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType } { const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => { @@ -77,53 +75,7 @@ describe('getTempPath', () => { }) }) -describe('throwLastError and throwWin32', () => { - it('throwLastError formats the system message when no detail is given', () => { - const { api } = formatApi() - let caught: unknown - try { - throwLastError(api, 'Probe') - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(Win32Error) - expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') - }) - - it('throwWin32 formats the system message when no detail is given', () => { - const { api } = formatApi() - let caught: unknown - try { - throwWin32(api, 'Probe', 5) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(Win32Error) - expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') - }) - - it('Win32Error appends the detail when one is given', () => { - const error = new Win32Error('Probe', 5, 'the lock file path') - expect(error.name).toBe('Win32Error') - expect(error.api).toBe('Probe') - expect(error.win32Code).toBe(5) - expect(error.message).toBe('Probe failed (Win32 5): the lock file path') - }) - - it('Win32Error omits the detail suffix when none is given', () => { - const error = new Win32Error('Probe', 5) - expect(error.message).toBe('Probe failed (Win32 5)') - }) -}) - -describe('pointer NULL handling', () => { - it('isNullPtr accepts null, undefined, and the zero pointer', () => { - expect(isNullPtr(null)).toBe(true) - expect(isNullPtr(undefined)).toBe(true) - expect(isNullPtr(0n as NativePtr)).toBe(true) - expect(isNullPtr(42n as NativePtr)).toBe(false) - }) - +describe('sandbox pointer handling', () => { it('isInvalidHandle treats NULL as failure', () => { expect(isInvalidHandle(null)).toBe(true) expect(isInvalidHandle(undefined)).toBe(true) @@ -142,11 +94,6 @@ describe('pointer NULL handling', () => { buffer.writeBigUInt64LE(42n, 0) expect(decodePtrAt(buffer, 0)).toBe(42n) }) - - it('decodePtr returns null for an unset out-parameter slot', () => { - const slot = koffi.alloc(PVOID, 1) as unknown as NativePtr - expect(decodePtr(slot)).toBeNull() - }) }) describe('sameSidAt bounded comparison', () => { diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 38b3e32200..b905fb1975 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -70,7 +70,7 @@ export interface Win32ProcessBindings { setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, - applicationName: string | null, + applicationName: null, commandLine: string, processAttributes: null, threadAttributes: null, From 4a722de4fa0218295ea31f9c43346e9818d3d5af Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:03:59 +0800 Subject: [PATCH 010/248] fix(win32-process): close PR1 review gaps --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 6 +- ...8-19-shared-win32-process-primitives.zh.md | 6 +- .github/workflows/ci.yml | 16 +++ .../sandbox-local/tests/packed-install.e2e.ts | 1 + .../sandbox/sandbox-windows-acl/src/index.ts | 1 + .../sandbox-windows-acl/tests/ffi.spec.ts | 9 +- .../tests/index-failure-paths.spec.ts | 13 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- packages/subprocess/win32-process/src/abi.ts | 8 ++ packages/subprocess/win32-process/src/ffi.ts | 25 +++- .../subprocess/win32-process/src/index.ts | 1 + .../win32-process/src/job-attribute.ts | 124 ++++++++++++++++++ .../subprocess/win32-process/src/process.ts | 50 +++---- .../win32-process/tests/job-attribute.spec.ts | 65 +++++++++ .../tests/process-allocation-failure.spec.ts | 25 +++- .../tests/process-failure-paths.spec.ts | 58 ++++++-- .../win32-process/tests/process.spec.ts | 68 ++++++++-- .../win32-process/tests/quote.spec.ts | 3 +- .../win32-process/verify/abi-probe.cpp | 8 ++ scripts/ci-workflow.spec.ts | 4 + 26 files changed, 435 insertions(+), 80 deletions(-) create mode 100644 packages/subprocess/win32-process/src/job-attribute.ts create mode 100644 packages/subprocess/win32-process/tests/job-attribute.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 053fadffc3..1eda0cef7b 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: ab23b02dfb4e937891b26b009900696ada3fa3c0 -2026-08-19-shared-win32-process-primitives.zh.md: e8686d9f4d1ac2d05c0eecf025ada19d491e50d2 +2026-08-19-shared-win32-process-primitives.md: 58bbd5a2ae44caf85dfca144d99efabb063243e2 +2026-08-19-shared-win32-process-primitives.zh.md: 5c4e63979412fbb617094ad1745f4a8318b49237 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index ab23b02dfb..58bbd5a2ae 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Successful inherited-stdio creation returns the process plus kill-on-close Job after the child is suspended, assigned to the Job, and resumed; assignment failure terminates the suspended child before releasing its handles. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so a successfully created suspended child is already Job-owned before resume; attribute, creation, or resume failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended Job assignment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile the header probe and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic suspended Job attachment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index e8686d9f4d..5c4e639794 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW`、`STARTUPINFOEXW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建成功时,child 已 suspended、指派给 Job 并 resume,随后把 process 与 kill-on-close Job 返回给 sandbox;指派失败会先终止 suspended child,再释放其 handles。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此成功创建的 suspended child 在 resume 前已经归属 Job;attribute、创建或 resume 失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的 suspended Job 指派、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的原子 suspended Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cab0cf791..e945879e1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -488,6 +488,22 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile + - name: Compile and run Win32 header ABI probes + shell: pwsh + run: | + $probeRoot = Join-Path $env:RUNNER_TEMP 'dsh-win32-abi-probes' + New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null + $processProbe = Join-Path $probeRoot 'win32-process.exe' + $sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' + g++ -std=c++20 -municode -O2 -o $processProbe packages/subprocess/win32-process/verify/abi-probe.cpp + if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe compilation failed' } + & $processProbe + if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe failed' } + g++ -std=c++20 -municode -O2 -o $sandboxProbe packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp -ladvapi32 + if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe compilation failed' } + & $sandboxProbe + if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe failed' } + - name: Run complete native Windows gate inventory shell: pwsh run: pnpm run check:ci:windows-complete diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index eded2b8d70..135ebb8494 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -32,6 +32,7 @@ const WORKSPACE_CLOSURE = [ // consumer resolves it like any other @deepseek-ai peer (koffi arrives // from the registry). 'packages/sandbox/sandbox-windows-acl', + 'packages/subprocess/win32-process', 'packages/sandbox/sandbox', 'packages/core/session', 'packages/core/scope', diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 10fe48be45..5a46eb1423 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -55,6 +55,7 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' +export { quoteArg, Win32Error } from '@deepseek-ai/dsh-win32-process' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 7c046ae87f..ebd3ba1b35 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -6,10 +6,10 @@ * acl.spec.ts and probe.spec.ts (win32 only). */ -import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' +import { Win32Error, quoteArg } from '../src/index.ts' import { allocBytes, decodePtrAt, getTempPath, isInvalidHandle, sameSidAt, @@ -75,6 +75,13 @@ describe('getTempPath', () => { }) }) +describe('public compatibility exports', () => { + it('keeps the sandbox Win32 error and quoting API', () => { + expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error) + expect(quoteArg('a b')).toBe('"a b"') + }) +}) + describe('sandbox pointer handling', () => { it('isInvalidHandle treats NULL as failure', () => { expect(isInvalidHandle(null)).toBe(true) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index adc1aaa8a1..4f0957ea65 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -145,7 +145,15 @@ function happyStubs(): HappyStubs { }) const createJobObjectW = vi.fn(() => fresh()) const setInformationJobObject = vi.fn(() => 1) - const assignProcessToJobObject = vi.fn(() => 1) + const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }) + const updateProcThreadAttribute = vi.fn(() => 1) + const deleteProcThreadAttributeList = vi.fn() const resumeThread = vi.fn(() => 0) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) @@ -160,7 +168,8 @@ function happyStubs(): HappyStubs { getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, - setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle, + setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute, + deleteProcThreadAttributeList, resumeThread, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 7cdeda55c3..62da073509 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 packages/subprocess/README.md -README.md: ba74f0d2ed2251c3527259b571663abf5bf740a2 -README.zh.md: fefc13d49b94ddba2e697d3481b4b991531540a8 +README.md: 56d6c04af92fa07673e3f8881bf20e47358fd001 +README.zh.md: 8b7db95e0196dbc98a67657478e4e242b4f7bca9 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index ba74f0d2ed..56d6c04af9 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,7 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | -| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, Job assignment, waits, and handle cleanup | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, atomic Job attachment, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index fefc13d49b..8b7db95e01 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | -| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、Job 指派、wait 与句柄清理的唯一 Koffi owner | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、原子 Job 附加、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 6e68e09233..909e34fecb 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: a18b1b8167e3ea76d61f022f4aa3ea827546d93f -README.zh.md: 262300f5da48aeed4fe7c05d970d498147921c49 +README.md: 53064791de5375cd05008509db4f9d27beec3dc3 +README.zh.md: f5c6bc8334908241b5e6a2332f79e3d3808f6469 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index a18b1b8167..53064791de 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -6,10 +6,10 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. -- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, restores the parent handle flags, and resumes the child. Creation, assignment, or resume failure closes every owned resource; assignment failure terminates the still-suspended child before releasing its process and thread handles. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, attaches that Job through `STARTUPINFOEXW`, creates the restricted child suspended and already Job-owned, restores the parent handle flags, and resumes the child. Attribute setup, creation, or resume failure closes every owned resource; no successful process creation can leave an unowned suspended child. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 262300f5da..f5c6bc8334 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -6,10 +6,10 @@ ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 -- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,将其指派给 Job,恢复父进程句柄标志,再 resume child。创建、指派或 resume 失败都会关闭全部已拥有资源;指派失败会先终止仍 suspended 的 child,再释放其 process 与 thread handles。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,通过 `STARTUPINFOEXW` 附加该 Job,以 suspended 且已经归属 Job 的状态创建 restricted child,恢复父进程句柄标志,再 resume child。attribute 设置、创建或 resume 失败都会关闭全部已拥有资源;成功创建进程后不会留下无 owner 的 suspended child。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index fbdda9059f..168879bf65 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -8,6 +8,10 @@ export const HANDLE_FLAG_INHERIT = 0x1 export const INFINITE = 0xFFFFFFFF /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 +/** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */ +export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +/** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */ +export const PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -34,5 +38,9 @@ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 /** x64 STARTUPINFOW byte size verified by the native probe. */ export const STARTUPINFOW_SIZE = 104 +/** x64 STARTUPINFOEXW byte size verified by the native probe. */ +export const STARTUPINFOEXW_SIZE = 112 +/** x64 pointer and HANDLE byte size. */ +export const POINTER_SIZE = 8 /** x64 PROCESS_INFORMATION byte size verified by the native probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b905fb1975..b22f096bfc 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -81,6 +81,22 @@ export interface Win32ProcessBindings { startupInfo: NativePtr, processInfo: NativePtr, ): number + initializeProcThreadAttributeList( + attributeList: Buffer | null, + attributeCount: number, + flags: number, + size: NativePtr, + ): number + updateProcThreadAttribute( + attributeList: Buffer, + flags: number, + attribute: number, + value: NativePtr, + size: number, + previousValue: null, + returnSize: null, + ): number + deleteProcThreadAttributeList(attributeList: Buffer): void readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, @@ -95,7 +111,6 @@ export interface Win32ProcessBindings { resumeThread(thread: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number - assignProcessToJobObject(job: NativePtr, process: NativePtr): number terminateProcess(process: NativePtr, exitCode: number): number getStdHandle(stdHandle: number): NativePtr } @@ -241,6 +256,13 @@ function bindings(): Win32ProcessBindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), + initializeProcThreadAttributeList: bind(kernel32, 'InitializeProcThreadAttributeList', 'int', [ + PVOID, 'uint32', 'uint32', koffi.pointer('size_t'), + ]), + updateProcThreadAttribute: bind(kernel32, 'UpdateProcThreadAttribute', 'int', [ + PVOID, 'uint32', 'size_t', PVOID, 'size_t', PVOID, PVOID, + ]), + deleteProcThreadAttributeList: bind(kernel32, 'DeleteProcThreadAttributeList', 'void', [PVOID]), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), @@ -250,7 +272,6 @@ function bindings(): Win32ProcessBindings { resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), - assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32ProcessBindings diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index fa7f2dd992..e7693db495 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -19,6 +19,7 @@ export type { export { closeHandleChecked, drainPipe, + quoteArg, spawnInheritedJobProcess, spawnPipedProcess, waitForProcessExit, diff --git a/packages/subprocess/win32-process/src/job-attribute.ts b/packages/subprocess/win32-process/src/job-attribute.ts new file mode 100644 index 0000000000..7798cc6eea --- /dev/null +++ b/packages/subprocess/win32-process/src/job-attribute.ts @@ -0,0 +1,124 @@ +/** Package-private STARTUPINFOEXW ownership for atomic Job attachment. */ + +import koffi from 'koffi' +import * as abi from './abi.ts' +import { STARTUPINFOW, throwWin32 } from './ffi.ts' +import type { NativePtr, StartupInfoInput, Win32ProcessBindings } from './ffi.ts' + +type Ptr = ReturnType +const PVOID: Ptr = koffi.pointer('void') + +const STARTUPINFOEXW = koffi.struct('DSH_STARTUPINFOEXW', { + StartupInfo: STARTUPINFOW, + lpAttributeList: PVOID, +}) + +/* v8 ignore start -- the native header probe pins this x64 layout. */ +if (STARTUPINFOEXW.size !== abi.STARTUPINFOEXW_SIZE) { + throw new Error(`STARTUPINFOEXW layout mismatch: koffi computed ${STARTUPINFOEXW.size}, expected ${abi.STARTUPINFOEXW_SIZE}`) +} +/* v8 ignore stop */ + +/** One extended startup record whose attribute list remains valid through CreateProcess. */ +export interface JobStartupInfo { + /** STARTUPINFOEXW pointer passed to CreateProcessAsUserW. */ + readonly pointer: NativePtr + /** Release the initialized process attribute list after CreateProcessAsUserW returns. */ + dispose(): void +} + +function queryAttributeListSize(api: Win32ProcessBindings): number { + const sizeSlot = koffi.alloc('size_t', 1) as NativePtr + try { + api.initializeProcThreadAttributeList(null, 1, 0, sizeSlot) + const attributeBytes = koffi.decode(sizeSlot, 'size_t') as number + if (attributeBytes === 0) { + throwWin32( + api, + 'InitializeProcThreadAttributeList', + api.getLastError(), + 'process-attribute size query', + ) + } + return attributeBytes + } finally { + koffi.free(sizeSlot) + } +} + +/** + * Build a STARTUPINFOEXW that assigns the restricted child to `job` during creation. + * @param api - active binding table. + * @param fields - inherited stdio fields for the nested STARTUPINFOW. + * @param job - caller-owned Job attached before any child thread exists. + * @returns extended startup pointer and its post-CreateProcess disposer. + */ +export function createJobStartupInfo( + api: Win32ProcessBindings, + fields: Omit, + job: NativePtr, +): JobStartupInfo { + const attributeList = Buffer.alloc(queryAttributeListSize(api)) + const sizeSlot = koffi.alloc('size_t', 1) as NativePtr + let initialized = false + let jobList: NativePtr | undefined + try { + koffi.encode(sizeSlot, 'size_t', attributeList.length) + if (api.initializeProcThreadAttributeList(attributeList, 1, 0, sizeSlot) === 0) { + throwWin32( + api, + 'InitializeProcThreadAttributeList', + api.getLastError(), + 'process-attribute initialization', + ) + } + initialized = true + jobList = koffi.alloc(PVOID, 1) as NativePtr + koffi.encode(jobList, PVOID, job) + if (api.updateProcThreadAttribute( + attributeList, + 0, + abi.PROC_THREAD_ATTRIBUTE_JOB_LIST, + jobList, + abi.POINTER_SIZE, + null, + null, + ) === 0) { + throwWin32( + api, + 'UpdateProcThreadAttribute', + api.getLastError(), + 'PROC_THREAD_ATTRIBUTE_JOB_LIST', + ) + } + const pointer = koffi.alloc(STARTUPINFOEXW, 1) as NativePtr + try { + koffi.encode(pointer, STARTUPINFOEXW, { + StartupInfo: { ...fields, cb: abi.STARTUPINFOEXW_SIZE }, + lpAttributeList: attributeList, + }) + } catch (error) { + /* v8 ignore start -- staging a STARTUPINFOEXW encode failure requires replacing Koffi's encoder. */ + koffi.free(pointer) + throw error + /* v8 ignore stop */ + } + return { + pointer, + dispose: () => { + try { + api.deleteProcThreadAttributeList(attributeList) + } finally { + koffi.free(jobList) + koffi.free(pointer) + } + }, + } + } catch (error) { + if (initialized) api.deleteProcThreadAttributeList(attributeList) + if (jobList !== undefined) koffi.free(jobList) + throw error + } finally { + koffi.free(sizeSlot) + } +} diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index f62195413f..ff5d7a9386 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -15,6 +15,7 @@ import { throwLastError, throwWin32, } from './ffi.ts' +import { createJobStartupInfo } from './job-attribute.ts' import type { NativePtr, Win32ProcessBindings } from './ffi.ts' /** @@ -77,7 +78,7 @@ export interface SpawnedPipedProcess { stderrRead: NativePtr } -/** Suspended-created child assigned to one caller-owned kill-on-close Job before resume. */ +/** Suspended-created child atomically attached to one caller-owned kill-on-close Job. */ export interface SpawnedJobProcess { /** Direct child process id. */ pid: number @@ -310,7 +311,7 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { } /** - * Spawn suspended inside a kill-on-close Job, then resume. + * Spawn suspended and atomically attached to a kill-on-close Job, then resume. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. * @returns caller-owned process and Job handles after successful resume. @@ -331,7 +332,6 @@ export function spawnInheritedJobProcess( const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] - let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined let created = 0 let createFailureCode = 0 @@ -346,27 +346,28 @@ export function spawnInheritedJobProcess( } enabled.push(handle) } - startupInfo = allocStartupInfo() - encodeStartupInfo(startupInfo, { - cb: abi.STARTUPINFOW_SIZE, + const startupInfo = createJobStartupInfo(api, { dwFlags: abi.STARTF_USESTDHANDLES, hStdInput: stdIn, hStdOutput: stdOut, hStdError: stdErr, - }) - processInfo = allocProcessInfo() - created = createRestrictedProcess( - api, - options, - buildCommandLine(options.command, options.args), - abi.CREATE_SUSPENDED, - startupInfo, - processInfo, - ) - if (created === 0) createFailureCode = api.getLastError() + }, job) + try { + processInfo = allocProcessInfo() + created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + abi.CREATE_SUSPENDED | abi.EXTENDED_STARTUPINFO_PRESENT, + startupInfo.pointer, + processInfo, + ) + if (created === 0) createFailureCode = api.getLastError() + } finally { + startupInfo.dispose() + } } catch (error) { freeNative(processInfo) - freeNative(startupInfo) api.closeHandle(job) throw error } finally { @@ -374,7 +375,6 @@ export function spawnInheritedJobProcess( } if (created === 0) { freeNative(processInfo) - freeNative(startupInfo) api.closeHandle(job) throwWin32( api, @@ -388,23 +388,13 @@ export function spawnInheritedJobProcess( info = decodeProcessInfo(processInfo) } finally { freeNative(processInfo) - freeNative(startupInfo) } if (info.hProcess === null || info.hThread === null) { - if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) + api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) - api.closeHandle(job) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } - if (api.assignProcessToJobObject(job, info.hProcess) === 0) { - const win32Code = api.getLastError() - api.terminateProcess(info.hProcess, 1) - closeBestEffort(api, info.hThread) - closeBestEffort(api, info.hProcess) - api.closeHandle(job) - throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) - } if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { const win32Code = api.getLastError() closeBestEffort(api, info.hThread) diff --git a/packages/subprocess/win32-process/tests/job-attribute.spec.ts b/packages/subprocess/win32-process/tests/job-attribute.spec.ts new file mode 100644 index 0000000000..793cb62bdd --- /dev/null +++ b/packages/subprocess/win32-process/tests/job-attribute.spec.ts @@ -0,0 +1,65 @@ +import koffi from 'koffi' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createJobStartupInfo } from '../src/job-attribute.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts' + +afterEach(() => { + vi.restoreAllMocks() +}) + +function bindings(): { + api: Win32ProcessBindings + deleteProcThreadAttributeList: ReturnType +} { + const deleteProcThreadAttributeList = vi.fn() + const api = { + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + return { api, deleteProcThreadAttributeList } +} + +const fields = { + dwFlags: 0x100, + hStdInput: 1n as NativePtr, + hStdOutput: 2n as NativePtr, + hStdError: 3n as NativePtr, +} + +describe('createJobStartupInfo allocation cleanup', () => { + it('frees the size slot when attribute-list buffer allocation throws', () => { + const { api, deleteProcThreadAttributeList } = bindings() + const free = vi.spyOn(koffi, 'free') + vi.spyOn(Buffer, 'alloc').mockImplementationOnce(() => { throw new Error('buffer allocation failed') }) + expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('buffer allocation failed') + expect(free).toHaveBeenCalledOnce() + expect(deleteProcThreadAttributeList).not.toHaveBeenCalled() + }) + + it('deletes the initialized list and frees the Job value when attachment fails', () => { + const { api, deleteProcThreadAttributeList } = bindings() + api.updateProcThreadAttribute = vi.fn(() => 0) + const free = vi.spyOn(koffi, 'free') + expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('PROC_THREAD_ATTRIBUTE_JOB_LIST') + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(free).toHaveBeenCalledTimes(3) + }) + + it('frees every native allocation after the caller disposes the startup record', () => { + const { api, deleteProcThreadAttributeList } = bindings() + const free = vi.spyOn(koffi, 'free') + const startup = createJobStartupInfo(api, fields, 50n as NativePtr) + startup.dispose() + expect(free).toHaveBeenCalledTimes(4) + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts index 714af104b2..b850292b6a 100644 --- a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -20,11 +20,21 @@ afterEach(() => { describe('spawnInheritedJobProcess allocation cleanup', () => { it('frees startup info when process-info allocation throws', () => { + const deleteProcThreadAttributeList = vi.fn() const api = { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList, closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -37,7 +47,8 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toThrow('process-info allocation failed') - expect(free).toHaveBeenCalledOnce() + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(free).toHaveBeenCalledTimes(4) }) it('frees process info after a successful inherited spawn', () => { @@ -46,6 +57,15 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList: vi.fn(), createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -55,7 +75,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { }) return 1 }), - assignProcessToJobObject: vi.fn(() => 1), resumeThread: vi.fn(() => 1), closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), @@ -68,7 +87,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(free).toHaveBeenCalledTimes(2) + expect(free).toHaveBeenCalledTimes(5) }) }) diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 9335fccbf0..917974a868 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -21,6 +21,23 @@ import { PROCESS_INFORMATION } from '../src/ffi.ts' const PVOID = koffi.pointer('void') +function jobAttributeStubs(): Pick< + Win32ProcessBindings, + 'initializeProcThreadAttributeList' | 'updateProcThreadAttribute' | 'deleteProcThreadAttributeList' +> { + return { + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList: vi.fn(), + } +} + /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType } { const closed: bigint[] = [] @@ -69,7 +86,7 @@ function resumeFailureApi(): { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) return 1 }), - assignProcessToJobObject: vi.fn(() => 1), + ...jobAttributeStubs(), resumeThread, getLastError: vi.fn(() => 5), closeHandle, @@ -239,7 +256,7 @@ describe('spawnInheritedJobProcess failure paths', () => { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) return 1 }), - assignProcessToJobObject: vi.fn(() => 1), + ...jobAttributeStubs(), resumeThread: vi.fn(() => 0), getLastError: vi.fn(() => 5), closeHandle, @@ -302,11 +319,34 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(100n) }) - it('terminates the suspended child when Job assignment fails', () => { - const terminateProcess = vi.fn(() => 1) + it('closes the job when the attribute-list size query returns no size', () => { const { api, closeHandle } = inheritedApi({ - assignProcessToJobObject: vi.fn(() => 0), - terminateProcess, + initializeProcThreadAttributeList: vi.fn(() => 0), + }) + expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) + .toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job when attribute-list initialization fails', () => { + const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 0 + }) + const { api, closeHandle } = inheritedApi({ initializeProcThreadAttributeList }) + expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) + .toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('deletes the attribute list and closes the job when atomic Job attachment fails', () => { + const deleteProcThreadAttributeList = vi.fn() + const { api, closeHandle } = inheritedApi({ + updateProcThreadAttribute: vi.fn(() => 0), + deleteProcThreadAttributeList, }) let caught: unknown try { @@ -314,10 +354,8 @@ describe('spawnInheritedJobProcess failure paths', () => { } catch (error) { caught = error } - expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 }) - expect(terminateProcess).toHaveBeenCalledWith(200n, 1) - expect(closeHandle).toHaveBeenCalledWith(201n) - expect(closeHandle).toHaveBeenCalledWith(200n) + expect(caught).toMatchObject({ api: 'UpdateProcThreadAttribute', win32Code: 5 }) + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() expect(closeHandle).toHaveBeenCalledWith(100n) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index ecee195b2c..b729c5d651 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -7,7 +7,12 @@ import { spawnInheritedJobProcess, spawnPipedProcess, } from '../src/index.ts' -import { CREATE_SUSPENDED } from '../src/abi.ts' +import { + CREATE_SUSPENDED, + EXTENDED_STARTUPINFO_PRESENT, + POINTER_SIZE, + PROC_THREAD_ATTRIBUTE_JOB_LIST, +} from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -17,9 +22,12 @@ function inheritedApi(overrides: Partial = {}): { api: Win32ProcessBindings events: string[] createProcessAsUserW: ReturnType - assignProcessToJobObject: ReturnType + initializeProcThreadAttributeList: ReturnType + updateProcThreadAttribute: ReturnType + attachedJob: () => NativePtr | null } { const events: string[] = [] + let attachedJob: NativePtr | null = null const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = overrides.createProcessAsUserW ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { @@ -33,7 +41,22 @@ function inheritedApi(overrides: Partial = {}): { return 1 }) const createProcessAsUserW = vi.fn(createProcessAsUserWImpl) - const assignProcessToJobObject = vi.fn(() => { events.push('assign'); return 1 }) + const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + events.push('attribute-size') + koffi.encode(size, 'size_t', 64) + return 0 + } + events.push('attribute-init') + return 1 + }) + const updateProcThreadAttribute = vi.fn((_list, _flags, attribute: number, value: NativePtr) => { + if (attribute === PROC_THREAD_ATTRIBUTE_JOB_LIST) { + attachedJob = koffi.decode(value, PVOID) as NativePtr + events.push('attach-job') + } + return 1 + }) const api = { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), @@ -42,7 +65,9 @@ function inheritedApi(overrides: Partial = {}): { events.push(flags === 0 ? 'restore' : 'inherit') return 1 }), - assignProcessToJobObject, + initializeProcThreadAttributeList, + updateProcThreadAttribute, + deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }), resumeThread: vi.fn(() => { events.push('resume'); return 1 }), terminateProcess: vi.fn(() => 1), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), @@ -55,7 +80,9 @@ function inheritedApi(overrides: Partial = {}): { api, events, createProcessAsUserW, - assignProcessToJobObject, + initializeProcThreadAttributeList, + updateProcThreadAttribute, + attachedJob: () => attachedJob, } } @@ -67,7 +94,9 @@ describe('spawnInheritedJobProcess', () => { api, events, createProcessAsUserW, - assignProcessToJobObject, + initializeProcThreadAttributeList, + updateProcThreadAttribute, + attachedJob, } = inheritedApi() const child = spawnInheritedJobProcess(api, { command: 'cmd.exe', @@ -76,9 +105,21 @@ describe('spawnInheritedJobProcess', () => { token, }) expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(events.indexOf('assign')).toBeGreaterThan(events.indexOf('create')) + expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create')) + expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create')) expect(events.indexOf('resume')).toBeGreaterThan(events.indexOf('create')) - expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n) + expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything()) + expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything()) + expect(updateProcThreadAttribute).toHaveBeenCalledWith( + expect.any(Buffer), + 0, + PROC_THREAD_ATTRIBUTE_JOB_LIST, + expect.anything(), + POINTER_SIZE, + null, + null, + ) + expect(attachedJob()).toBe(50n) expect(createProcessAsUserW).toHaveBeenCalledWith( token, null, @@ -86,7 +127,7 @@ describe('spawnInheritedJobProcess', () => { null, null, 1, - CREATE_SUSPENDED, + CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, null, 'C:\\work', expect.anything(), @@ -149,10 +190,10 @@ describe('spawnInheritedJobProcess', () => { expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 }) }) - it('terminates a restricted child when CreateProcessAsUserW returns a null thread handle', () => { - const terminateProcess = vi.fn(() => 1) + it('closes the atomic Job when CreateProcessAsUserW returns a null thread handle', () => { + const closeHandle = vi.fn(() => 1) const { api } = inheritedApi({ - terminateProcess, + closeHandle, createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -169,7 +210,8 @@ describe('spawnInheritedJobProcess', () => { cwd: 'C:\\work', token, })).toThrow('null process/thread handles') - expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + expect(closeHandle).toHaveBeenCalledWith(50n) + expect(closeHandle).toHaveBeenCalledWith(60n) }) }) diff --git a/packages/subprocess/win32-process/tests/quote.spec.ts b/packages/subprocess/win32-process/tests/quote.spec.ts index 63dcc7bc77..f93d721cc3 100644 --- a/packages/subprocess/win32-process/tests/quote.spec.ts +++ b/packages/subprocess/win32-process/tests/quote.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildCommandLine, quoteArg } from '../src/process.ts' +import { quoteArg } from '../src/index.ts' +import { buildCommandLine } from '../src/process.ts' const isWin32 = process.platform === 'win32' diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 347fafdd3c..0b0911ea93 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -13,11 +13,15 @@ int wmain() P(offsetof(STARTUPINFOW, hStdInput)); P(offsetof(STARTUPINFOW, hStdOutput)); P(offsetof(STARTUPINFOW, hStdError)); + P(sizeof(STARTUPINFOEXW)); + P(offsetof(STARTUPINFOEXW, lpAttributeList)); P(sizeof(PROCESS_INFORMATION)); P(offsetof(PROCESS_INFORMATION, hProcess)); P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); P(CREATE_SUSPENDED); + P(EXTENDED_STARTUPINFO_PRESENT); + P(PROC_THREAD_ATTRIBUTE_JOB_LIST); P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); @@ -35,8 +39,12 @@ int wmain() P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size"); + static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); static_assert(CREATE_SUSPENDED == 0x4, "create suspended"); + static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag"); + static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 45712860e5..df60db0626 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -78,6 +78,10 @@ describe('CI workflow', () => { const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) + expect(nativeCommandSteps.some(step => ( + step.run.includes('packages/subprocess/win32-process/verify/abi-probe.cpp') + && step.run.includes('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') + ))).toBe(true) expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') // wine-apt-cache: master-only, seeds the Wine apt cache. From 5490a5e070899fb22a00964216697610069ee78f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:09:38 +0800 Subject: [PATCH 011/248] ci(windows): run ABI probes with MSVC --- .github/workflows/ci.yml | 19 +++++++++++-------- scripts/ci-workflow.spec.ts | 2 ++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e945879e1b..88ec032d62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -495,14 +495,17 @@ jobs: New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null $processProbe = Join-Path $probeRoot 'win32-process.exe' $sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' - g++ -std=c++20 -municode -O2 -o $processProbe packages/subprocess/win32-process/verify/abi-probe.cpp - if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe compilation failed' } - & $processProbe - if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe failed' } - g++ -std=c++20 -municode -O2 -o $sandboxProbe packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp -ladvapi32 - if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe compilation failed' } - & $sandboxProbe - if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe failed' } + $vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } + $vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() + if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } + $vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' + if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } + $processSource = Join-Path $PWD 'packages/subprocess/win32-process/verify/abi-probe.cpp' + $sandboxSource = Join-Path $PWD 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' + $probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" + & cmd.exe /d /s /c $probeCommand + if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } - name: Run complete native Windows gate inventory shell: pwsh diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index df60db0626..3f0b9047ef 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -81,6 +81,8 @@ describe('CI workflow', () => { expect(nativeCommandSteps.some(step => ( step.run.includes('packages/subprocess/win32-process/verify/abi-probe.cpp') && step.run.includes('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') + && step.run.includes('vswhere.exe') + && step.run.includes('vcvars64.bat') ))).toBe(true) expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') From 4f381b83c969c57c6f03c58676c620232fb04628 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:23:43 +0800 Subject: [PATCH 012/248] refactor(win32-process): remove redundant suspension --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 4 +- ...8-19-shared-win32-process-primitives.zh.md | 4 +- .../sandbox/sandbox-windows-acl/src/spawn.ts | 2 +- .../tests/index-failure-paths.spec.ts | 3 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 2 +- packages/subprocess/win32-process/src/abi.ts | 2 - packages/subprocess/win32-process/src/ffi.ts | 2 - .../subprocess/win32-process/src/process.ts | 15 ++---- .../tests/process-allocation-failure.spec.ts | 1 - .../tests/process-failure-paths.spec.ts | 52 ------------------- .../win32-process/tests/process.spec.ts | 7 +-- .../win32-process/verify/abi-probe.cpp | 2 - 15 files changed, 18 insertions(+), 88 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 1eda0cef7b..02bb27c70f 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 58bbd5a2ae44caf85dfca144d99efabb063243e2 -2026-08-19-shared-win32-process-primitives.zh.md: 5c4e63979412fbb617094ad1745f4a8318b49237 +2026-08-19-shared-win32-process-primitives.md: 8e5878ff23a49d9f0fbc4e62b9e3e6bccf5fe4ed +2026-08-19-shared-win32-process-primitives.zh.md: 90ca5b21227136febafa946d20acbe59630436fd diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 58bbd5a2ae..8e5878ff23 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,13 +14,13 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so a successfully created suspended child is already Job-owned before resume; attribute, creation, or resume failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic suspended Job attachment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic Job attachment during creation, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 5c4e639794..90ca5b2122 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,13 +14,13 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此成功创建的 suspended child 在 resume 前已经归属 Job;attribute、创建或 resume 失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的原子 suspended Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、创建时的原子 Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index a36b0253c4..3336a309f1 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -39,7 +39,7 @@ export function spawnSandboxed( * @param api - ACL/token binding table. * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns process and Job handles after assignment and resume. + * @returns process and Job handles after atomic attachment during creation. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 4f0957ea65..a8834a8c61 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -154,7 +154,6 @@ function happyStubs(): HappyStubs { }) const updateProcThreadAttribute = vi.fn(() => 1) const deleteProcThreadAttributeList = vi.fn() - const resumeThread = vi.fn(() => 0) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) const closeHandle = vi.fn(() => 1) @@ -169,7 +168,7 @@ function happyStubs(): HappyStubs { setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute, - deleteProcThreadAttributeList, resumeThread, getStdHandle, + deleteProcThreadAttributeList, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 909e34fecb..4fdca725f6 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 53064791de5375cd05008509db4f9d27beec3dc3 -README.zh.md: f5c6bc8334908241b5e6a2332f79e3d3808f6469 +README.md: 601c095ea48fbf46f151ee69ad2e485881dcb4af +README.zh.md: 53a55ef58a404c52ded1d417ba4fa516a3092783 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 53064791de..601c095ea4 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -9,7 +9,7 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the - **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. -- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, attaches that Job through `STARTUPINFOEXW`, creates the restricted child suspended and already Job-owned, restores the parent handle flags, and resumes the child. Attribute setup, creation, or resume failure closes every owned resource; no successful process creation can leave an unowned suspended child. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, and attaches that Job through `STARTUPINFOEXW` while creating the restricted child. The child is Job-owned before any user code can run; attribute setup or creation failure closes every owned resource, and no successful process creation can leave an unowned child. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index f5c6bc8334..53a55ef58a 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -9,7 +9,7 @@ - **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 -- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,通过 `STARTUPINFOEXW` 附加该 Job,以 suspended 且已经归属 Job 的状态创建 restricted child,恢复父进程句柄标志,再 resume child。attribute 设置、创建或 resume 失败都会关闭全部已拥有资源;成功创建进程后不会留下无 owner 的 suspended child。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,并在创建 restricted child 时通过 `STARTUPINFOEXW` 附加该 Job。child 会在任何用户代码运行前归属 Job;attribute 设置或创建失败都会关闭全部已拥有资源,成功创建进程后不会留下无 owner 的 child。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 168879bf65..9409e9025b 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,8 +6,6 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** CreateProcess flag that prevents user code from running before resume. */ -export const CREATE_SUSPENDED = 0x4 /** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */ export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 /** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b22f096bfc..4abea75c5e 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -108,7 +108,6 @@ export interface Win32ProcessBindings { ): number waitForSingleObject(handle: NativePtr, milliseconds: number): number getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number - resumeThread(thread: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number terminateProcess(process: NativePtr, exitCode: number): number @@ -269,7 +268,6 @@ function bindings(): Win32ProcessBindings { ]), waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), - resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index ff5d7a9386..7404ccb4ba 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -78,7 +78,7 @@ export interface SpawnedPipedProcess { stderrRead: NativePtr } -/** Suspended-created child atomically attached to one caller-owned kill-on-close Job. */ +/** Child atomically attached to one caller-owned kill-on-close Job during creation. */ export interface SpawnedJobProcess { /** Direct child process id. */ pid: number @@ -311,10 +311,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { } /** - * Spawn suspended and atomically attached to a kill-on-close Job, then resume. + * Spawn atomically attached to a kill-on-close Job. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. - * @returns caller-owned process and Job handles after successful resume. + * @returns caller-owned process and Job handles after successful creation. */ export function spawnInheritedJobProcess( api: Win32ProcessBindings, @@ -358,7 +358,7 @@ export function spawnInheritedJobProcess( api, options, buildCommandLine(options.command, options.args), - abi.CREATE_SUSPENDED | abi.EXTENDED_STARTUPINFO_PRESENT, + abi.EXTENDED_STARTUPINFO_PRESENT, startupInfo.pointer, processInfo, ) @@ -395,13 +395,6 @@ export function spawnInheritedJobProcess( closeBestEffort(api, info.hProcess) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } - if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { - const win32Code = api.getLastError() - closeBestEffort(api, info.hThread) - closeBestEffort(api, info.hProcess) - api.closeHandle(job) - throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) - } closeBestEffort(api, info.hThread) return { pid: info.dwProcessId, process: info.hProcess, job } } diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts index b850292b6a..f9e115e8d0 100644 --- a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -75,7 +75,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { }) return 1 }), - resumeThread: vi.fn(() => 1), closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 917974a868..93a501c197 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -61,40 +61,6 @@ function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeH return { api, closed, closeHandle } } -/** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */ -function resumeFailureApi(): { - api: Win32ProcessBindings - closed: bigint[] - closeHandle: ReturnType -} { - const closed: bigint[] = [] - let std = 50n - const closeHandle = vi.fn((handle: NativePtr) => { - closed.push(handle) - return 1 - }) - const resumeThread = vi.fn(() => 0xFFFFFFFF) - const api = { - createJobObjectW: vi.fn(() => 100n), - setInformationJobObject: vi.fn(() => 1), - getStdHandle: vi.fn(() => std++), - setHandleInformation: vi.fn(() => 1), - createProcessAsUserW: vi.fn(( - _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, - _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, - ) => { - koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) - return 1 - }), - ...jobAttributeStubs(), - resumeThread, - getLastError: vi.fn(() => 5), - closeHandle, - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - return { api, closed, closeHandle } -} - describe('spawn failure paths close their handles', () => { // A dummy token value; the stubbed spawn never reads it. const token = 1n as NativePtr @@ -114,23 +80,6 @@ describe('spawn failure paths close their handles', () => { expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n]) }) - it('closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { - const { api, closed, closeHandle } = resumeFailureApi() - let caught: unknown - try { - spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(Win32Error) - expect((caught as Win32Error).api).toBe('ResumeThread') - expect((caught as Win32Error).win32Code).toBe(5) - // thread, process, job — closing the job triggers kill-on-close so the - // suspended child dies instead of hanging until this process exits. - expect(closeHandle).toHaveBeenCalledTimes(3) - expect(closed).toEqual([201n, 200n, 100n]) - }) - }) /** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */ @@ -257,7 +206,6 @@ describe('spawnInheritedJobProcess failure paths', () => { return 1 }), ...jobAttributeStubs(), - resumeThread: vi.fn(() => 0), getLastError: vi.fn(() => 5), closeHandle, formatMessageW: vi.fn(() => 0), diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index b729c5d651..c734d13013 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -8,7 +8,6 @@ import { spawnPipedProcess, } from '../src/index.ts' import { - CREATE_SUSPENDED, EXTENDED_STARTUPINFO_PRESENT, POINTER_SIZE, PROC_THREAD_ATTRIBUTE_JOB_LIST, @@ -68,7 +67,6 @@ function inheritedApi(overrides: Partial = {}): { initializeProcThreadAttributeList, updateProcThreadAttribute, deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }), - resumeThread: vi.fn(() => { events.push('resume'); return 1 }), terminateProcess: vi.fn(() => 1), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), getLastError: vi.fn(() => 5), @@ -89,7 +87,7 @@ function inheritedApi(overrides: Partial = {}): { describe('spawnInheritedJobProcess', () => { const token = 70n as NativePtr - it('attaches a restricted suspended child to the Job inside CreateProcessAsUserW', () => { + it('attaches a restricted child to the Job inside CreateProcessAsUserW', () => { const { api, events, @@ -107,7 +105,6 @@ describe('spawnInheritedJobProcess', () => { expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create')) expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create')) - expect(events.indexOf('resume')).toBeGreaterThan(events.indexOf('create')) expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything()) expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything()) expect(updateProcThreadAttribute).toHaveBeenCalledWith( @@ -127,7 +124,7 @@ describe('spawnInheritedJobProcess', () => { null, null, 1, - CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, + EXTENDED_STARTUPINFO_PRESENT, null, 'C:\\work', expect.anything(), diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 0b0911ea93..50452bd514 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -19,7 +19,6 @@ int wmain() P(offsetof(PROCESS_INFORMATION, hProcess)); P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); - P(CREATE_SUSPENDED); P(EXTENDED_STARTUPINFO_PRESENT); P(PROC_THREAD_ATTRIBUTE_JOB_LIST); P(STARTF_USESTDHANDLES); @@ -42,7 +41,6 @@ int wmain() static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size"); static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(CREATE_SUSPENDED == 0x4, "create suspended"); static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag"); static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); From 4605124732ba004f6fafc42a8489c3b814a8d309 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:49:56 +0800 Subject: [PATCH 013/248] ci(windows): exercise ABI probes on failover standby --- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +-- .../process/2026-07-26-ci-failover-runbook.md | 2 +- .../2026-07-26-ci-failover-runbook.zh.md | 2 +- .github/workflows/ci.yml | 21 ++++------------ scripts/ci-workflow.spec.ts | 21 ++++++++++------ scripts/verify-win32-abi.ps1 | 25 +++++++++++++++++++ 6 files changed, 47 insertions(+), 28 deletions(-) create mode 100644 scripts/verify-win32-abi.ps1 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index f8cdf8e924..55592adfb6 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc -2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2 +2026-07-26-ci-failover-runbook.md: c4d1677d8f8f632ae31cf5bcfbbd5386c9932919 +2026-07-26-ci-failover-runbook.zh.md: bce9054e051d8c919b038337922174e33ad60f9c diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index e8a1d1dc33..c4d1677d8f 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -24,7 +24,7 @@ The decision belongs at workflow level because cancellation applies to the whole #### Windows pool -`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. +`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, Visual Studio C++ Build Tools with the x64 MSVC toolchain and Windows SDK, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: before the complete aggregate, that lane compiles and runs the same two Win32 header ABI probes as `windows-native`, so a green standby verifies both the compiler prerequisite and `check:ci:windows-complete` end-to-end. ### Switch (any repository writer, ~1 minute, no merge) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 8f92b7b60c..bce9054e05 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -24,7 +24,7 @@ Status: implemented #### Windows 池 -`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池能端到端执行 `check:ci:windows-complete`。 +`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7、带 x64 MSVC 工具链与 Windows SDK 的 Visual Studio C++ Build Tools,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:该通道会在完整聚合前编译并运行与 `windows-native` 相同的两份 Win32 header ABI probe,因此绿色热备会同时验证编译器前置条件与 `check:ci:windows-complete` 端到端流程。 ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88ec032d62..959aad6b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -490,22 +490,7 @@ jobs: - name: Compile and run Win32 header ABI probes shell: pwsh - run: | - $probeRoot = Join-Path $env:RUNNER_TEMP 'dsh-win32-abi-probes' - New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null - $processProbe = Join-Path $probeRoot 'win32-process.exe' - $sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' - $vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' - if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } - $vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() - if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } - $vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' - if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } - $processSource = Join-Path $PWD 'packages/subprocess/win32-process/verify/abi-probe.cpp' - $sandboxSource = Join-Path $PWD 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' - $probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" - & cmd.exe /d /s /c $probeCommand - if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } + run: ./scripts/verify-win32-abi.ps1 - name: Run complete native Windows gate inventory shell: pwsh @@ -710,6 +695,10 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile + - name: Compile and run Win32 header ABI probes + shell: pwsh + run: ./scripts/verify-win32-abi.ps1 + - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 3f0b9047ef..0b563cb469 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -49,8 +49,8 @@ describe('CI workflow', () => { const node24Coverage = workflow.jobs['node-24-coverage'] const node24Consumers = workflow.jobs['node-24-consumers'] const aggregate = workflow.jobs['all-checks-passed'] - if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { - throw new TypeError('Windows job must define steps and the aggregate must define needs') + if (!Array.isArray(windows.steps) || !Array.isArray(serialWindows.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows jobs must define steps and the aggregate must define needs') } const commandSteps = windows.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' @@ -78,12 +78,7 @@ describe('CI workflow', () => { const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) - expect(nativeCommandSteps.some(step => ( - step.run.includes('packages/subprocess/win32-process/verify/abi-probe.cpp') - && step.run.includes('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') - && step.run.includes('vswhere.exe') - && step.run.includes('vcvars64.bat') - ))).toBe(true) + expect(nativeCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') // wine-apt-cache: master-only, seeds the Wine apt cache. @@ -94,6 +89,16 @@ describe('CI workflow', () => { expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') + const serialWindowsCommandSteps = serialWindows.steps.filter((step): step is Record & { run: string } => ( + isRecord(step) && typeof step.run === 'string' + )) + expect(serialWindowsCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') + expect(serialWindowsCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') + const abiProbeScript = readFileSync(resolve(root, 'scripts/verify-win32-abi.ps1'), 'utf8') + expect(abiProbeScript).toContain('vswhere.exe') + expect(abiProbeScript).toContain('vcvars64.bat') + expect(abiProbeScript).toContain('packages/subprocess/win32-process/verify/abi-probe.cpp') + expect(abiProbeScript).toContain('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') diff --git a/scripts/verify-win32-abi.ps1 b/scripts/verify-win32-abi.ps1 new file mode 100644 index 0000000000..c0125c9eab --- /dev/null +++ b/scripts/verify-win32-abi.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } +$probeRoot = Join-Path $temporaryRoot 'dsh-win32-abi-probes' +New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null + +$vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } +$vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() +if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } +$vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' +if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } + +$processProbe = Join-Path $probeRoot 'win32-process.exe' +$processObject = Join-Path $probeRoot 'win32-process.obj' +$processSource = Join-Path $repoRoot 'packages/subprocess/win32-process/verify/abi-probe.cpp' +$sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' +$sandboxObject = Join-Path $probeRoot 'sandbox-windows-acl.obj' +$sandboxSource = Join-Path $repoRoot 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' + +$probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$processObject`" /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$sandboxObject`" /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" +& cmd.exe /d /s /c $probeCommand +if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } From 5375142827147d1c08c1dafca05948a227e5521c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 06:35:50 +0800 Subject: [PATCH 014/248] fix(sandbox): contain drain failure settlement --- ...-shared-win32-process-primitives.i18n.yaml | 4 +-- ...6-08-19-shared-win32-process-primitives.md | 2 +- ...8-19-shared-win32-process-primitives.zh.md | 2 +- AGENTS.md | 2 +- packages/README.i18n.yaml | 4 +-- packages/README.md | 2 +- packages/README.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/index.ts | 29 +++++++++++++------ .../sandbox/sandbox-windows-acl/src/token.ts | 5 ++-- .../sandbox-windows-acl/src/win32-abi.ts | 6 +++- .../sandbox-windows-acl/tests/ffi.spec.ts | 9 +++--- .../tests/index-failure-paths.spec.ts | 18 ++++++++++++ .../subprocess/win32-process/README.i18n.yaml | 4 +-- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 4 +-- .../subprocess/win32-process/src/index.ts | 1 - .../subprocess/win32-process/src/process.ts | 12 +++++++- .../win32-process/tests/quote.spec.ts | 3 +- 18 files changed, 76 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 02bb27c70f..0159e844a6 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 8e5878ff23a49d9f0fbc4e62b9e3e6bccf5fe4ed -2026-08-19-shared-win32-process-primitives.zh.md: 90ca5b21227136febafa946d20acbe59630436fd +2026-08-19-shared-win32-process-primitives.md: 60e9b5b76154a4833979b014dffb4015cb0c5c36 +2026-08-19-shared-win32-process-primitives.zh.md: 83bb71ddbe182084097c54325172a3f923b33138 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 8e5878ff23..60e9b5b761 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait, or closes the process handle and reports the termination failure without blocking. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 90ca5b2122..83bb71ddbe 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child,若终止本身失败则关闭 process handle 并报告该失败,不阻塞事件循环。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 diff --git a/AGENTS.md b/AGENTS.md index 99b31077db..f50a5b24d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM capability: Service Definition/Consumer + DeepSeek providers e2b/ E2B POC: sandbox + FS/subprocess adapters shell/ bash capability: Service Definition + local/pwsh providers + shell Consumers - subprocess/ subprocess capability + local process-tree provider + subprocess/ subprocess capability + local process-tree provider + shared Win32 library terminal/ persistent sessions fs/ filesystem capability + policy lsp/ language-server capability diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index ee425f39d7..ba5767f75b 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.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 packages/README.md -README.md: a410d7148d14503a61edb9c4848b521552050ca4 -README.zh.md: 780d1356f2095c7dcc526e5cdac8994267e4a460 +README.md: defbd29942f7733919a9c65a1f8174148919b034 +README.zh.md: f47584209d4d54eedec48f5f5c4e90479cf5c7d9 diff --git a/packages/README.md b/packages/README.md index a410d7148d..defbd29942 100644 --- a/packages/README.md +++ b/packages/README.md @@ -19,7 +19,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`identity/`](identity/README.md) | Shared anonymous identity | Product — stable API | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API | | [`e2b/`](e2b/README.md) | E2B providers | POC | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable API | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local process-tree provider, and shared Win32 process library | Product — stable API | | [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API | | [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index 780d1356f2..f47584209d 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -19,7 +19,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`identity/`](identity/README.md) | 共享匿名身份 | 产品:稳定 API | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API | | [`e2b/`](e2b/README.md) | E2B 提供方 | POC | -| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | 产品:稳定 API | +| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition、本地进程树提供方与共享 Win32 进程库 | 产品:稳定 API | | [`shell/`](shell/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API | | [`terminal/`](terminal/README.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API | diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 5a46eb1423..231d1cb0c3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -55,7 +55,7 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' -export { quoteArg, Win32Error } from '@deepseek-ai/dsh-win32-process' +export { Win32Error } from '@deepseek-ai/dsh-win32-process' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { @@ -382,10 +382,11 @@ export class AclSandbox { const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) const stdout = drainPipe(api, native.stdoutRead) const stderr = drainPipe(api, native.stderrRead) - // waitForExit is deliberately NOT started here: WaitForSingleObject blocks - // the thread and would starve the drains while the child is still running - // (pipe-buffer deadlock). The drains resolve only after the child closed - // its pipe ends — by then the wait returns immediately. + // WaitForSingleObject blocks the thread, so settlement starts it only after + // both drains settle. Successful drains mean the child closed its pipe ends + // and the wait returns immediately. A failed drain terminates the child + // before waiting, so a native pipe failure cannot pin the event loop on a + // still-running command. let settlement: Promise | undefined return { pid: native.pid, @@ -394,10 +395,20 @@ export class AclSandbox { const failures = drains.flatMap(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : []) let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) + if (failures.length > 0 && api.terminateProcess(native.process, 1) === 0) { + const terminationCode = api.getLastError() + try { + closeHandleChecked(api, native.process, 'piped child after drain failure') + } catch (error) { + failures.push(error) + } + failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) + } else { + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } } if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index abd0c73617..96aa53b277 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -180,8 +180,9 @@ export interface RestrictingSidSet { * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's * Public tree grants write to INTERACTIVE, so removing it closes that - * escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts - * for the verified failure modes. FAILS CLOSED: any failure throws — never + * escape. S-1-2-1 (console logon) is intentionally absent: the package + * README's "Console isolation is unavailable" entry records the verified + * failure modes. FAILS CLOSED: any failure throws — never * spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 019f894a6c..478fc71e1d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -20,7 +20,11 @@ export const FILE_GENERIC_WRITE = 0x00120116 export const DELETE = 0x00010000 /** Delete or rename a directory child. */ export const FILE_DELETE_CHILD = 0x0040 -/** Capability-SID access mask granting write, delete, and child deletion. */ +/** + * Capability-SID access mask granting write, delete, and child deletion. + * WRITE_DAC and WRITE_OWNER stay excluded so a confined child cannot rewrite + * DACLs or take ownership to escape the allowlist. + */ export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE /** Full access used in the restricted token default DACL. */ export const FILE_ALL_ACCESS = 0x1F01FF diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index ebd3ba1b35..761598a073 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { Win32Error, quoteArg } from '../src/index.ts' +import { Win32Error } from '../src/index.ts' import { allocBytes, decodePtrAt, getTempPath, isInvalidHandle, sameSidAt, @@ -17,7 +17,7 @@ import { import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' -/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */ +/** A stub whose formatMessageW supplies text to the GetTempPath failure path. */ function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType } { const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => { const text = 'access denied' @@ -75,10 +75,9 @@ describe('getTempPath', () => { }) }) -describe('public compatibility exports', () => { - it('keeps the sandbox Win32 error and quoting API', () => { +describe('public error export', () => { + it('keeps the sandbox Win32 error type', () => { expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error) - expect(quoteArg('a b')).toBe('"a b"') }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index a8834a8c61..a75f3546d3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -448,6 +448,8 @@ describe('AclSandbox spawn', () => { it('pipe spawn still closes the process after a drain failure', async () => { const { api } = state.stubs as HappyStubs api.getLastError = vi.fn(() => 5) + const terminateProcess = vi.fn(() => 1) + api.terminateProcess = terminateProcess const waitForSingleObject = vi.fn(() => 0) api.waitForSingleObject = waitForSingleObject const workspace = scratch() @@ -460,8 +462,24 @@ describe('AclSandbox spawn', () => { expect.objectContaining({ api: 'PeekNamedPipe' }), ], }) + expect(terminateProcess).toHaveBeenCalledOnce() expect(waitForSingleObject).toHaveBeenCalledOnce() }) + + it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { + const { api, closeHandle } = state.stubs as HappyStubs + api.getLastError = vi.fn(() => 5) + api.terminateProcess = vi.fn(() => 0) + const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') }) + api.waitForSingleObject = waitForSingleObject + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-3', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toBeInstanceOf(AggregateError) + expect(waitForSingleObject).not.toHaveBeenCalled() + expect(closeHandle).toHaveBeenCalled() + }) }) describe('AclSandbox dispose', () => { diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 4fdca725f6..1f15b27c96 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 601c095ea48fbf46f151ee69ad2e485881dcb4af -README.zh.md: 53a55ef58a404c52ded1d417ba4fa516a3092783 +README.md: c3bc0d74c3c5a375d289e9a3e4037648341f4906 +README.zh.md: 6763f2b24633d7dd250eecdafe5e1724ffa29fa6 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 601c095ea4..c3bc0d74c3 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -34,6 +34,6 @@ The package contributes no stable request prefix, so it does not invalidate mode - **Windows-only native loading** — importing the generic types is portable, but resolving the binding table loads Windows DLLs and fails on other hosts. Cross-platform tests inject a binding table instead of loading native APIs. - **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. -- **Inherited environment only** — process creation passes a null environment block. Callers that need environment changes must establish them before invoking the primitive or use their own runner process. +- **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process. - **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. - **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 53a55ef58a..6763f2b246 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -26,7 +26,7 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 没有直接影响。消费方决定进程输出是否进入工具结果或后续模型请求。 -#### KV Cache effect +#### KV Cache 影响 本包不贡献稳定请求前缀,因此不会使模型 KV Cache 失效。 @@ -34,6 +34,6 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 - **仅在 Windows 原生加载** — 导入通用类型可跨平台进行,但解析绑定表会加载 Windows DLL,并在其他宿主失败。跨平台测试注入绑定表,不加载原生 API。 - **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 -- **只继承环境** — 进程创建传入空环境块。需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 +- **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW` 以 `ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 - **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 - **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index e7693db495..fa7f2dd992 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -19,7 +19,6 @@ export type { export { closeHandleChecked, drainPipe, - quoteArg, spawnInheritedJobProcess, spawnPipedProcess, waitForProcessExit, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 7404ccb4ba..c9fb888515 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -142,6 +142,9 @@ function createRestrictedProcess( startupInfo: NativePtr, processInfo: NativePtr, ): number { + // The sandbox mutates its process environment before this call. Passing an + // explicit block through Koffi makes CreateProcessAsUserW reject the request + // with ERROR_INVALID_PARAMETER, so lpEnvironment remains NULL. return api.createProcessAsUserW( options.token, null, @@ -315,6 +318,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. * @returns caller-owned process and Job handles after successful creation. + * @remarks Node clears stdio handle inheritability at startup through + * uv_disable_stdio_inheritance. This operation temporarily restores the bits + * required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort: + * failure must not replace the already-created child's outcome. */ export function spawnInheritedJobProcess( api: Win32ProcessBindings, @@ -371,7 +378,10 @@ export function spawnInheritedJobProcess( api.closeHandle(job) throw error } finally { - for (const handle of enabled) api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + for (const handle of enabled) { + // The runner spawns nothing else; cleanup failure must not mask the child. + api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } } if (created === 0) { freeNative(processInfo) diff --git a/packages/subprocess/win32-process/tests/quote.spec.ts b/packages/subprocess/win32-process/tests/quote.spec.ts index f93d721cc3..63dcc7bc77 100644 --- a/packages/subprocess/win32-process/tests/quote.spec.ts +++ b/packages/subprocess/win32-process/tests/quote.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' -import { quoteArg } from '../src/index.ts' -import { buildCommandLine } from '../src/process.ts' +import { buildCommandLine, quoteArg } from '../src/process.ts' const isWin32 = process.platform === 'win32' From 33e90e991943374577d4a35883142b203dbf3974 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 06:58:54 +0800 Subject: [PATCH 015/248] fix(sandbox): terminate on first drain failure --- .../sandbox/sandbox-windows-acl/src/index.ts | 41 ++++++++++------ .../tests/index-failure-paths.spec.ts | 49 +++++++++++++++++++ 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 231d1cb0c3..ecd7eb4cce 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -391,24 +391,35 @@ export class AclSandbox { return { pid: native.pid, wait: () => (settlement ??= (async () => { - const drains = await Promise.allSettled([stdout, stderr]) + let drains: PromiseSettledResult[] + try { + const [stdoutBuffer, stderrBuffer] = await Promise.all([stdout, stderr]) + drains = [ + { status: 'fulfilled', value: stdoutBuffer }, + { status: 'fulfilled', value: stderrBuffer }, + ] + } catch (firstDrainFailure) { + if (api.terminateProcess(native.process, 1) === 0) { + const failures: unknown[] = [firstDrainFailure] + const terminationCode = api.getLastError() + try { + closeHandleChecked(api, native.process, 'piped child after drain failure') + } catch (error) { + failures.push(error) + } + failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) + void Promise.allSettled([stdout, stderr]) + throw new AggregateError(failures, 'piped child settlement failed') + } + drains = await Promise.allSettled([stdout, stderr]) + } const failures = drains.flatMap(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : []) let exitCode = 0 - if (failures.length > 0 && api.terminateProcess(native.process, 1) === 0) { - const terminationCode = api.getLastError() - try { - closeHandleChecked(api, native.process, 'piped child after drain failure') - } catch (error) { - failures.push(error) - } - failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) - } else { - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) } if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index a75f3546d3..78032e2c32 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -466,6 +466,38 @@ describe('AclSandbox spawn', () => { expect(waitForSingleObject).toHaveBeenCalledOnce() }) + it('pipe spawn terminates promptly when one drain fails and the sibling remains open', async () => { + const { api } = state.stubs as HappyStubs + let peekCount = 0 + let terminated = false + let lastError = 5 + api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { + peekCount += 1 + if (peekCount === 1) return 0 + if (terminated) { + lastError = ERROR_BROKEN_PIPE + return 0 + } + koffi.encode(totalAvail, 'uint32', 0) + return 1 + }) + api.getLastError = vi.fn(() => lastError) + const terminateProcess = vi.fn(() => { + terminated = true + return 1 + }) + api.terminateProcess = terminateProcess + const waitForSingleObject = vi.fn(() => 0) + api.waitForSingleObject = waitForSingleObject + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2-1', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + expect(terminateProcess).toHaveBeenCalledOnce() + expect(waitForSingleObject).toHaveBeenCalledOnce() + }) + it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { const { api, closeHandle } = state.stubs as HappyStubs api.getLastError = vi.fn(() => 5) @@ -480,6 +512,23 @@ describe('AclSandbox spawn', () => { expect(waitForSingleObject).not.toHaveBeenCalled() expect(closeHandle).toHaveBeenCalled() }) + + it('pipe spawn aggregates process-handle closure failure after termination failure', async () => { + const { api } = state.stubs as HappyStubs + api.getLastError = vi.fn(() => 5) + api.terminateProcess = vi.fn(() => 0) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-4', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + api.closeHandle = vi.fn(() => 0) + await expect(child.wait()).rejects.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ api: 'CloseHandle' }), + expect.objectContaining({ api: 'TerminateProcess' }), + ]), + }) + }) }) describe('AclSandbox dispose', () => { From f9a264c76e7173548848889565b8fa5376263e11 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 07:15:00 +0800 Subject: [PATCH 016/248] test(sandbox): keep aggregate failure assertion typed --- .../tests/index-failure-paths.spec.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 78032e2c32..bbcddefb22 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -522,12 +522,13 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) api.closeHandle = vi.fn(() => 0) - await expect(child.wait()).rejects.toMatchObject({ - errors: expect.arrayContaining([ - expect.objectContaining({ api: 'CloseHandle' }), - expect.objectContaining({ api: 'TerminateProcess' }), - ]), - }) + const failure = await child.wait().catch((error: unknown): unknown => error) + expect(failure).toBeInstanceOf(AggregateError) + if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') + const apis = (failure.errors as unknown[]) + .filter((error): error is Win32Error => error instanceof Win32Error) + .map(error => error.api) + expect(apis).toEqual(expect.arrayContaining(['CloseHandle', 'TerminateProcess'])) }) }) From 9241ac22af33877dd55d4c506b0df4de1e3dc333 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 07:21:00 +0800 Subject: [PATCH 017/248] test(sandbox): preserve rejection evidence --- .../sandbox-windows-acl/tests/index-failure-paths.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index bbcddefb22..5db35403ec 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -522,7 +522,9 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) api.closeHandle = vi.fn(() => 0) - const failure = await child.wait().catch((error: unknown): unknown => error) + const settlement = child.wait() + await expect(settlement).rejects.toBeInstanceOf(AggregateError) + const failure = await settlement.catch((error: unknown): unknown => error) expect(failure).toBeInstanceOf(AggregateError) if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') const apis = (failure.errors as unknown[]) From 8505d61f6965e95f330f09828bb420479891d9b3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 07:24:36 +0800 Subject: [PATCH 018/248] test(sandbox): remove duplicate failure assertion --- .../sandbox-windows-acl/tests/index-failure-paths.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 5db35403ec..a4844e598f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -525,7 +525,6 @@ describe('AclSandbox spawn', () => { const settlement = child.wait() await expect(settlement).rejects.toBeInstanceOf(AggregateError) const failure = await settlement.catch((error: unknown): unknown => error) - expect(failure).toBeInstanceOf(AggregateError) if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') const apis = (failure.errors as unknown[]) .filter((error): error is Win32Error => error instanceof Win32Error) From 60587b4901c779fb00931545ec6c326ff07c6fc4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 08:06:38 +0800 Subject: [PATCH 019/248] fix(sandbox): cancel sibling drain on termination failure --- ...9-shared-win32-process-primitives.i18n.yaml | 4 ++-- ...26-08-19-shared-win32-process-primitives.md | 2 +- ...08-19-shared-win32-process-primitives.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 ++ .../sandbox/sandbox-windows-acl/src/index.ts | 8 +++++--- .../sandbox/sandbox-windows-acl/src/token.ts | 6 +++--- .../tests/index-failure-paths.spec.ts | 10 ++++++++++ .../subprocess/win32-process/README.i18n.yaml | 4 ++-- packages/subprocess/win32-process/README.md | 2 +- packages/subprocess/win32-process/README.zh.md | 2 +- .../subprocess/win32-process/src/errors.ts | 2 +- .../subprocess/win32-process/src/process.ts | 9 ++++++++- .../win32-process/tests/process.spec.ts | 18 ++++++++++++++++++ .../subprocess/win32-process/tsconfig.json | 3 +++ 14 files changed, 58 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 0159e844a6..5e1aeb4d0f 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 60e9b5b76154a4833979b014dffb4015cb0c5c36 -2026-08-19-shared-win32-process-primitives.zh.md: 83bb71ddbe182084097c54325172a3f923b33138 +2026-08-19-shared-win32-process-primitives.md: ae7720273333f675b9fb4178405bedbc32982a59 +2026-08-19-shared-win32-process-primitives.zh.md: 2d8b9d3421fa4eb4100f4f016018301e21de1eef diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 60e9b5b761..ae77202733 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait, or closes the process handle and reports the termination failure without blocking. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait. When termination itself fails, settlement cancels and joins the sibling drain before closing the process handle and reporting the failure, so rejection leaves no polling timer alive. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 83bb71ddbe..2d8b9d3421 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child,若终止本身失败则关闭 process handle 并报告该失败,不阻塞事件循环。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child。若终止本身失败,settlement 会先取消并等待 sibling drain 结束,再关闭 process handle 并报告失败,因此 rejection 不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 18e262a66b..983f5953f8 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -139,6 +139,8 @@ export function allocBytes(length: number): NativePtr { /** * Allocate one zeroed x64 OVERLAPPED record. * @returns allocated pointer. + * @remarks Koffi 3.1.1 crashes when LockFileEx or UnlockFileEx receives NULL; + * a zeroed OVERLAPPED is equivalent for the synchronous lock-file handle. */ export function allocOverlapped(): NativePtr { return allocBytes(32) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index ecd7eb4cce..2dc33306af 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -380,8 +380,9 @@ export class AclSandbox { } const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) - const stdout = drainPipe(api, native.stdoutRead) - const stderr = drainPipe(api, native.stderrRead) + const drainAbort = new AbortController() + const stdout = drainPipe(api, native.stdoutRead, drainAbort.signal) + const stderr = drainPipe(api, native.stderrRead, drainAbort.signal) // WaitForSingleObject blocks the thread, so settlement starts it only after // both drains settle. Successful drains mean the child closed its pipe ends // and the wait returns immediately. A failed drain terminates the child @@ -402,13 +403,14 @@ export class AclSandbox { if (api.terminateProcess(native.process, 1) === 0) { const failures: unknown[] = [firstDrainFailure] const terminationCode = api.getLastError() + drainAbort.abort() + await Promise.allSettled([stdout, stderr]) try { closeHandleChecked(api, native.process, 'piped child after drain failure') } catch (error) { failures.push(error) } failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) - void Promise.allSettled([stdout, stderr]) throw new AggregateError(failures, 'piped child settlement failed') } drains = await Promise.allSettled([stdout, stderr]) diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 96aa53b277..f1f2befbdd 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -180,9 +180,9 @@ export interface RestrictingSidSet { * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's * Public tree grants write to INTERACTIVE, so removing it closes that - * escape. S-1-2-1 (console logon) is intentionally absent: the package - * README's "Console isolation is unavailable" entry records the verified - * failure modes. FAILS CLOSED: any failure throws — never + * escape. S-1-2-1 (console logon) is intentionally absent: the package + * README's "Console isolation is unavailable" entry records the verified + * failure modes. FAILS CLOSED: any failure throws — never * spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index a4844e598f..41ca5463f1 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -500,6 +500,13 @@ describe('AclSandbox spawn', () => { it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { const { api, closeHandle } = state.stubs as HappyStubs + let peekCount = 0 + api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { + peekCount += 1 + if (peekCount === 1) return 0 + koffi.encode(totalAvail, 'uint32', 0) + return 1 + }) api.getLastError = vi.fn(() => 5) api.terminateProcess = vi.fn(() => 0) const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') }) @@ -509,6 +516,9 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) await expect(child.wait()).rejects.toBeInstanceOf(AggregateError) + const settledPeekCount = peekCount + await new Promise(resolve => setTimeout(resolve, 5)) + expect(peekCount).toBe(settledPeekCount) expect(waitForSingleObject).not.toHaveBeenCalled() expect(closeHandle).toHaveBeenCalled() }) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 1f15b27c96..24f7b1f607 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: c3bc0d74c3c5a375d289e9a3e4037648341f4906 -README.zh.md: 6763f2b24633d7dd250eecdafe5e1724ffa29fa6 +README.md: 3e82c10b7894b15d970b794429c69c6923632bb9 +README.zh.md: b1afc1a7222189329cbd84d9729fbafc3ecd3acb diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index c3bc0d74c3..3e82c10b78 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,7 +10,7 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, and attaches that Job through `STARTUPINFOEXW` while creating the restricted child. The child is Job-owned before any user code can run; attribute setup or creation failure closes every owned resource, and no successful process creation can leave an unowned child. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining, accepts cancellation that stops polling, and frees its allocation before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 6763f2b246..b1afc1a722 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,7 +10,7 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,并在创建 restricted child 时通过 `STARTUPINFOEXW` 附加该 Job。child 会在任何用户代码运行前归属 Job;attribute 设置或创建失败都会关闭全部已拥有资源,成功创建进程后不会留下无 owner 的 child。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,接受停止轮询的取消信号,并在关闭管道读取句柄前释放原生分配;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts index 84bd2a7ac2..f6e90805a1 100644 --- a/packages/subprocess/win32-process/src/errors.ts +++ b/packages/subprocess/win32-process/src/errors.ts @@ -2,7 +2,7 @@ export class Win32Error extends Error { /** Win32 function whose checked result failed. */ readonly api: string - /** Exact GetLastError value captured before cleanup changed it. */ + /** Exact GetLastError value or direct Win32 API error code. */ readonly win32Code: number constructor(api: string, win32Code: number, detail?: string) { diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index c9fb888515..f63943728e 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -240,14 +240,21 @@ export function spawnPipedProcess( * Drain one anonymous pipe until the writer closes it. * @param api - active binding table. * @param handle - caller-owned pipe read end. + * @param signal - optional cancellation that stops polling and closes the read end. * @returns complete bytes read before EOF; the handle is always closed. + * @throws when cancellation or a Win32 pipe operation fails. */ -export async function drainPipe(api: Win32ProcessBindings, handle: NativePtr): Promise { +export async function drainPipe( + api: Win32ProcessBindings, + handle: NativePtr, + signal?: AbortSignal, +): Promise { const chunks: Buffer[] = [] let countSlot: NativePtr | undefined try { countSlot = allocUint32() for (;;) { + if (signal?.aborted === true) throw new Error('pipe drain aborted') const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) if (peeked === 0) { const win32Code = api.getLastError() diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index c734d13013..15cf2e62c6 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -234,6 +234,24 @@ describe('wait and pipe cleanup', () => { expect(closeHandle).toHaveBeenCalledWith(80n) }) + it('stops polling and closes the read end when cancelled', async () => { + const controller = new AbortController() + const closeHandle = vi.fn(() => 1) + const peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, available) => { + koffi.encode(available, 'uint32', 0) + return 1 + }) + const api = { + peekNamedPipe, + closeHandle, + } as unknown as Win32ProcessBindings + const draining = drainPipe(api, 80n as NativePtr, controller.signal) + controller.abort() + await expect(draining).rejects.toThrow('pipe drain aborted') + expect(peekNamedPipe).toHaveBeenCalledOnce() + expect(closeHandle).toHaveBeenCalledWith(80n) + }) + it('checks caller-owned handle closure', () => { const closeHandle = vi.fn(() => 1) const api = { closeHandle } as unknown as Win32ProcessBindings diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json index 2f159cfc48..730993dc97 100644 --- a/packages/subprocess/win32-process/tsconfig.json +++ b/packages/subprocess/win32-process/tsconfig.json @@ -6,6 +6,9 @@ }, "include": ["src"], "references": [ + { + "path": "../../../vendor/cordis" + }, { "path": "../../runtime-diagnostics/invariants" } From 03186fe93f1215fb2f322a42802aa6993a91b7c9 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 08:45:54 +0800 Subject: [PATCH 020/248] fix(sandbox): cancel sibling drain after child termination --- ...-shared-win32-process-primitives.i18n.yaml | 4 ++-- ...6-08-19-shared-win32-process-primitives.md | 2 +- ...8-19-shared-win32-process-primitives.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/index.ts | 22 +++++++++++-------- .../tests/index-failure-paths.spec.ts | 16 +++++--------- .../subprocess/win32-process/src/process.ts | 4 ++-- .../win32-process/tests/process.spec.ts | 5 +++-- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 5e1aeb4d0f..4e5dac463c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: ae7720273333f675b9fb4178405bedbc32982a59 -2026-08-19-shared-win32-process-primitives.zh.md: 2d8b9d3421fa4eb4100f4f016018301e21de1eef +2026-08-19-shared-win32-process-primitives.md: a190fbd78d3f6e33e5626b01a38a9c2cfbb8216f +2026-08-19-shared-win32-process-primitives.zh.md: 79e1ae576bc0d14d0e4f152825171d3d8510bb70 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index ae77202733..a190fbd78d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait. When termination itself fails, settlement cancels and joins the sibling drain before closing the process handle and reporting the failure, so rejection leaves no polling timer alive. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement requests direct-child termination, cancels and joins the sibling drain, then performs the direct-child wait only when termination succeeded. A termination failure instead closes the process handle and reports both failures. Either result leaves no polling timer alive even when a descendant inherited a pipe writer. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 2d8b9d3421..79e1ae576b 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child。若终止本身失败,settlement 会先取消并等待 sibling drain 结束,再关闭 process handle 并报告失败,因此 rejection 不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会请求终止 direct child,取消并等待 sibling drain,再只在终止成功时执行 direct-child wait。若终止本身失败,则关闭 process handle 并同时报告两项失败。即使 descendant 继承了 pipe writer,两种结果也都不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 2dc33306af..64d49faf35 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -381,13 +381,14 @@ export class AclSandbox { const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) const drainAbort = new AbortController() + const drainCancellation = new Error('piped child drain cancelled after peer failure') const stdout = drainPipe(api, native.stdoutRead, drainAbort.signal) const stderr = drainPipe(api, native.stderrRead, drainAbort.signal) // WaitForSingleObject blocks the thread, so settlement starts it only after // both drains settle. Successful drains mean the child closed its pipe ends - // and the wait returns immediately. A failed drain terminates the child - // before waiting, so a native pipe failure cannot pin the event loop on a - // still-running command. + // and the wait returns immediately. A failed drain cancels its sibling and + // terminates the child before waiting, so inherited pipe writers cannot pin + // the event loop after settlement. let settlement: Promise | undefined return { pid: native.pid, @@ -400,11 +401,12 @@ export class AclSandbox { { status: 'fulfilled', value: stderrBuffer }, ] } catch (firstDrainFailure) { - if (api.terminateProcess(native.process, 1) === 0) { + const terminated = api.terminateProcess(native.process, 1) + const terminationCode = terminated === 0 ? api.getLastError() : 0 + drainAbort.abort(drainCancellation) + const settledDrains = await Promise.allSettled([stdout, stderr]) + if (terminated === 0) { const failures: unknown[] = [firstDrainFailure] - const terminationCode = api.getLastError() - drainAbort.abort() - await Promise.allSettled([stdout, stderr]) try { closeHandleChecked(api, native.process, 'piped child after drain failure') } catch (error) { @@ -413,10 +415,12 @@ export class AclSandbox { failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) throw new AggregateError(failures, 'piped child settlement failed') } - drains = await Promise.allSettled([stdout, stderr]) + drains = settledDrains } const failures = drains.flatMap(outcome => - outcome.status === 'rejected' ? [outcome.reason as unknown] : []) + outcome.status === 'rejected' && outcome.reason !== drainCancellation + ? [outcome.reason as unknown] + : []) let exitCode = 0 try { exitCode = waitForExit(api, native.process) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 41ca5463f1..159b36428f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -469,23 +469,14 @@ describe('AclSandbox spawn', () => { it('pipe spawn terminates promptly when one drain fails and the sibling remains open', async () => { const { api } = state.stubs as HappyStubs let peekCount = 0 - let terminated = false - let lastError = 5 api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { peekCount += 1 if (peekCount === 1) return 0 - if (terminated) { - lastError = ERROR_BROKEN_PIPE - return 0 - } koffi.encode(totalAvail, 'uint32', 0) return 1 }) - api.getLastError = vi.fn(() => lastError) - const terminateProcess = vi.fn(() => { - terminated = true - return 1 - }) + api.getLastError = vi.fn(() => 5) + const terminateProcess = vi.fn(() => 1) api.terminateProcess = terminateProcess const waitForSingleObject = vi.fn(() => 0) api.waitForSingleObject = waitForSingleObject @@ -494,6 +485,9 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) await expect(child.wait()).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + const settledPeekCount = peekCount + await new Promise(resolve => setTimeout(resolve, 5)) + expect(peekCount).toBe(settledPeekCount) expect(terminateProcess).toHaveBeenCalledOnce() expect(waitForSingleObject).toHaveBeenCalledOnce() }) diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index f63943728e..4b948e1849 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -242,7 +242,7 @@ export function spawnPipedProcess( * @param handle - caller-owned pipe read end. * @param signal - optional cancellation that stops polling and closes the read end. * @returns complete bytes read before EOF; the handle is always closed. - * @throws when cancellation or a Win32 pipe operation fails. + * @throws when the drain is cancelled or a Win32 pipe operation fails. */ export async function drainPipe( api: Win32ProcessBindings, @@ -254,7 +254,7 @@ export async function drainPipe( try { countSlot = allocUint32() for (;;) { - if (signal?.aborted === true) throw new Error('pipe drain aborted') + signal?.throwIfAborted() const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) if (peeked === 0) { const win32Code = api.getLastError() diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index 15cf2e62c6..e2f151c8e1 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -246,8 +246,9 @@ describe('wait and pipe cleanup', () => { closeHandle, } as unknown as Win32ProcessBindings const draining = drainPipe(api, 80n as NativePtr, controller.signal) - controller.abort() - await expect(draining).rejects.toThrow('pipe drain aborted') + const cancellation = new Error('stop pipe drain') + controller.abort(cancellation) + await expect(draining).rejects.toBe(cancellation) expect(peekNamedPipe).toHaveBeenCalledOnce() expect(closeHandle).toHaveBeenCalledWith(80n) }) From a163f4019b15437844951842288aa7002166c9a7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 08:58:12 +0800 Subject: [PATCH 021/248] fix(sandbox): preserve all drain failures --- packages/sandbox/sandbox-windows-acl/src/index.ts | 7 +++++-- .../sandbox-windows-acl/tests/index-failure-paths.spec.ts | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 64d49faf35..989f31d86a 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -400,13 +400,16 @@ export class AclSandbox { { status: 'fulfilled', value: stdoutBuffer }, { status: 'fulfilled', value: stderrBuffer }, ] - } catch (firstDrainFailure) { + } catch { const terminated = api.terminateProcess(native.process, 1) const terminationCode = terminated === 0 ? api.getLastError() : 0 drainAbort.abort(drainCancellation) const settledDrains = await Promise.allSettled([stdout, stderr]) if (terminated === 0) { - const failures: unknown[] = [firstDrainFailure] + const failures = settledDrains.flatMap(outcome => + outcome.status === 'rejected' && outcome.reason !== drainCancellation + ? [outcome.reason as unknown] + : []) try { closeHandleChecked(api, native.process, 'piped child after drain failure') } catch (error) { diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 159b36428f..5cad94742a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -530,9 +530,11 @@ describe('AclSandbox spawn', () => { await expect(settlement).rejects.toBeInstanceOf(AggregateError) const failure = await settlement.catch((error: unknown): unknown => error) if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') - const apis = (failure.errors as unknown[]) + const errors = failure.errors as unknown[] + const apis = errors .filter((error): error is Win32Error => error instanceof Win32Error) .map(error => error.api) + expect(apis.filter(api => api === 'PeekNamedPipe')).toHaveLength(2) expect(apis).toEqual(expect.arrayContaining(['CloseHandle', 'TerminateProcess'])) }) }) From 5b47da02aee90da2b369b0a8c1beb08968859caf Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 15:21:01 +0800 Subject: [PATCH 022/248] refactor(win32-process): restore mechanical extraction --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 8 +- ...8-19-shared-win32-process-primitives.zh.md | 8 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 2 +- .../2026-07-26-ci-failover-runbook.zh.md | 2 +- .github/workflows/ci.yml | 8 - .../sandbox/sandbox-windows-acl/src/index.ts | 96 +++-------- .../sandbox/sandbox-windows-acl/src/spawn.ts | 2 +- .../tests/index-failure-paths.spec.ts | 150 +----------------- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 7 +- .../subprocess/win32-process/README.zh.md | 7 +- packages/subprocess/win32-process/src/abi.ts | 10 +- packages/subprocess/win32-process/src/ffi.ts | 27 +--- .../subprocess/win32-process/src/index.ts | 1 - .../win32-process/src/job-attribute.ts | 124 --------------- .../subprocess/win32-process/src/process.ts | 74 +++++---- .../win32-process/tests/job-attribute.spec.ts | 65 -------- .../tests/process-allocation-failure.spec.ts | 26 +-- .../tests/process-failure-paths.spec.ts | 74 ++++----- .../win32-process/tests/process.spec.ts | 137 +++++++--------- .../win32-process/verify/abi-probe.cpp | 10 +- scripts/ci-workflow.spec.ts | 15 +- scripts/verify-win32-abi.ps1 | 25 --- 28 files changed, 188 insertions(+), 710 deletions(-) delete mode 100644 packages/subprocess/win32-process/src/job-attribute.ts delete mode 100644 packages/subprocess/win32-process/tests/job-attribute.spec.ts delete mode 100644 scripts/verify-win32-abi.ps1 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 4e5dac463c..780aa7e236 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: a190fbd78d3f6e33e5626b01a38a9c2cfbb8216f -2026-08-19-shared-win32-process-primitives.zh.md: 79e1ae576bc0d14d0e4f152825171d3d8510bb70 +2026-08-19-shared-win32-process-primitives.md: 8765e5f7350dab56ad42169f6e16b55679ca8982 +2026-08-19-shared-win32-process-primitives.zh.md: b21ece8445e8863c08818c42d6c9bf7672813823 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index a190fbd78d..8765e5f735 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement requests direct-child termination, cancels and joins the sibling drain, then performs the direct-child wait only when termination succeeded. A termination failure instead closes the process handle and reports both failures. Either result leaves no polling timer alive even when a descendant inherited a pipe writer. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation starts the target suspended, assigns it to the kill-on-close Job, and resumes it only after assignment, so target code cannot run outside the Job. Assignment failure terminates the suspended target before releasing its handles; resume failure closes the assigned Job. The sandbox retains its existing pipe-drain, direct-wait, result, and returned-Job lifecycle. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic Job attachment during creation, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended creation followed by Job assignment and resume, wait and exit-code reads, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the migrated ABI and native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -32,4 +32,4 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, ## Consequences -The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Future process consumers extend the low-level package only when their production path exists. +The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Suspended creation guarantees that target code starts only after Job assignment, but it does not make the runner's create-to-assignment interval atomic against external termination. Future process consumers extend the low-level package only when their production path exists. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 79e1ae576b..b21ece8445 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW`、`STARTUPINFOEXW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会请求终止 direct child,取消并等待 sibling drain,再只在终止成功时执行 direct-child wait。若终止本身失败,则关闭 process handle 并同时报告两项失败。即使 descendant 继承了 pipe writer,两种结果也都不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。分配失败会先终止 suspended target 再释放句柄;恢复失败会关闭已经分配的 Job。sandbox 保留既有 pipe-drain、direct-wait、result 与返回 Job 的生命周期。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、创建时的原子 Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、suspended 创建后的 Job 分配与恢复、wait 与 exit-code 读取、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖迁移后的 ABI 和 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -32,4 +32,4 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF ## Consequences -sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。后续 process consumer 只在其生产路径存在时扩展低层 package。 +sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。suspended 创建保证目标代码只在 Job 分配后启动,但不会让 runner 的 create-to-assignment 区间对外部终止具备原子性。后续 process consumer 只在其生产路径存在时扩展低层 package。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 55592adfb6..f8cdf8e924 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: c4d1677d8f8f632ae31cf5bcfbbd5386c9932919 -2026-07-26-ci-failover-runbook.zh.md: bce9054e051d8c919b038337922174e33ad60f9c +2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc +2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index c4d1677d8f..e8a1d1dc33 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -24,7 +24,7 @@ The decision belongs at workflow level because cancellation applies to the whole #### Windows pool -`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, Visual Studio C++ Build Tools with the x64 MSVC toolchain and Windows SDK, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: before the complete aggregate, that lane compiles and runs the same two Win32 header ABI probes as `windows-native`, so a green standby verifies both the compiler prerequisite and `check:ci:windows-complete` end-to-end. +`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. ### Switch (any repository writer, ~1 minute, no merge) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index bce9054e05..8f92b7b60c 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -24,7 +24,7 @@ Status: implemented #### Windows 池 -`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7、带 x64 MSVC 工具链与 Windows SDK 的 Visual Studio C++ Build Tools,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:该通道会在完整聚合前编译并运行与 `windows-native` 相同的两份 Win32 header ABI probe,因此绿色热备会同时验证编译器前置条件与 `check:ci:windows-complete` 端到端流程。 +`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池能端到端执行 `check:ci:windows-complete`。 ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002de361b8..741a6c4d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -506,10 +506,6 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Compile and run Win32 header ABI probes - shell: pwsh - run: ./scripts/verify-win32-abi.ps1 - - name: Run complete native Windows gate inventory shell: pwsh run: pnpm run check:ci:windows-complete @@ -645,10 +641,6 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Compile and run Win32 header ABI probes - shell: pwsh - run: ./scripts/verify-win32-abi.ps1 - - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 989f31d86a..9e4568c726 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -42,7 +42,7 @@ import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' -import { closeHandleChecked, Win32Error } from '@deepseek-ai/dsh-win32-process' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { grantWrite, revokeWrite } from './acl.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' @@ -356,88 +356,34 @@ export class AclSandbox { if (options.stdio === 'inherit') { const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) - let settlement: Promise | undefined + let exitCodePromise: Promise | undefined return { pid: native.pid, - wait: () => (settlement ??= new Promise((resolveResult) => { - const failures: unknown[] = [] - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - try { - closeHandleChecked(api, native.job, 'kill-on-close job') - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') - resolveResult({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode }) - })), + wait: async () => { + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + const exitCode = await exitCodePromise + if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + }, } } const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) - const drainAbort = new AbortController() - const drainCancellation = new Error('piped child drain cancelled after peer failure') - const stdout = drainPipe(api, native.stdoutRead, drainAbort.signal) - const stderr = drainPipe(api, native.stderrRead, drainAbort.signal) - // WaitForSingleObject blocks the thread, so settlement starts it only after - // both drains settle. Successful drains mean the child closed its pipe ends - // and the wait returns immediately. A failed drain cancels its sibling and - // terminates the child before waiting, so inherited pipe writers cannot pin - // the event loop after settlement. - let settlement: Promise | undefined + const stdout = drainPipe(api, native.stdoutRead) + const stderr = drainPipe(api, native.stderrRead) + // waitForExit is deliberately NOT started here: WaitForSingleObject blocks + // the thread and would starve the drains while the child is still running + // (pipe-buffer deadlock). The drains resolve only after the child closed + // its pipe ends — by then the wait returns immediately. + let exitCodePromise: Promise | undefined return { pid: native.pid, - wait: () => (settlement ??= (async () => { - let drains: PromiseSettledResult[] - try { - const [stdoutBuffer, stderrBuffer] = await Promise.all([stdout, stderr]) - drains = [ - { status: 'fulfilled', value: stdoutBuffer }, - { status: 'fulfilled', value: stderrBuffer }, - ] - } catch { - const terminated = api.terminateProcess(native.process, 1) - const terminationCode = terminated === 0 ? api.getLastError() : 0 - drainAbort.abort(drainCancellation) - const settledDrains = await Promise.allSettled([stdout, stderr]) - if (terminated === 0) { - const failures = settledDrains.flatMap(outcome => - outcome.status === 'rejected' && outcome.reason !== drainCancellation - ? [outcome.reason as unknown] - : []) - try { - closeHandleChecked(api, native.process, 'piped child after drain failure') - } catch (error) { - failures.push(error) - } - failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) - throw new AggregateError(failures, 'piped child settlement failed') - } - drains = settledDrains - } - const failures = drains.flatMap(outcome => - outcome.status === 'rejected' && outcome.reason !== drainCancellation - ? [outcome.reason as unknown] - : []) - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') - return { - stdout: (drains[0] as PromiseFulfilledResult).value, - stderr: (drains[1] as PromiseFulfilledResult).value, - exitCode, - } - })()), + wait: async () => { + const stdoutBuffer = await stdout + const stderrBuffer = await stderr + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } + }, } } diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 3336a309f1..a36b0253c4 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -39,7 +39,7 @@ export function spawnSandboxed( * @param api - ACL/token binding table. * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns process and Job handles after atomic attachment during creation. + * @returns process and Job handles after assignment and resume. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 5cad94742a..64899d9db8 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -145,15 +145,9 @@ function happyStubs(): HappyStubs { }) const createJobObjectW = vi.fn(() => fresh()) const setInformationJobObject = vi.fn(() => 1) - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }) - const updateProcThreadAttribute = vi.fn(() => 1) - const deleteProcThreadAttributeList = vi.fn() + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const terminateProcess = vi.fn(() => 1) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) const closeHandle = vi.fn(() => 1) @@ -167,8 +161,8 @@ function happyStubs(): HappyStubs { getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, - setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute, - deleteProcThreadAttributeList, getStdHandle, + setInformationJobObject, assignProcessToJobObject, resumeThread, terminateProcess, + getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { @@ -403,140 +397,6 @@ describe('AclSandbox spawn', () => { jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) }) - - it('inherit spawn caches one failing settlement and closes the Job once', async () => { - const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) - const jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - expect(closeHandle.mock.calls.filter(([handle]) => handle === jobHandle)).toHaveLength(1) - }) - - it('inherit spawn aggregates wait and Job-close failures', async () => { - const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-1', mode: 'workspace-write' }) - await sandbox.init() - let jobHandle = 0n - closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) - const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) - jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr - await expect(child.wait()).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ api: 'WaitForSingleObject' }), - expect.objectContaining({ api: 'CloseHandle' }), - ], - }) - }) - - it('pipe spawn reports a wait failure after successful drains', async () => { - const { api } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-2', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - }) - - it('pipe spawn still closes the process after a drain failure', async () => { - const { api } = state.stubs as HappyStubs - api.getLastError = vi.fn(() => 5) - const terminateProcess = vi.fn(() => 1) - api.terminateProcess = terminateProcess - const waitForSingleObject = vi.fn(() => 0) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ api: 'PeekNamedPipe' }), - expect.objectContaining({ api: 'PeekNamedPipe' }), - ], - }) - expect(terminateProcess).toHaveBeenCalledOnce() - expect(waitForSingleObject).toHaveBeenCalledOnce() - }) - - it('pipe spawn terminates promptly when one drain fails and the sibling remains open', async () => { - const { api } = state.stubs as HappyStubs - let peekCount = 0 - api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { - peekCount += 1 - if (peekCount === 1) return 0 - koffi.encode(totalAvail, 'uint32', 0) - return 1 - }) - api.getLastError = vi.fn(() => 5) - const terminateProcess = vi.fn(() => 1) - api.terminateProcess = terminateProcess - const waitForSingleObject = vi.fn(() => 0) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2-1', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'PeekNamedPipe' }) - const settledPeekCount = peekCount - await new Promise(resolve => setTimeout(resolve, 5)) - expect(peekCount).toBe(settledPeekCount) - expect(terminateProcess).toHaveBeenCalledOnce() - expect(waitForSingleObject).toHaveBeenCalledOnce() - }) - - it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { - const { api, closeHandle } = state.stubs as HappyStubs - let peekCount = 0 - api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { - peekCount += 1 - if (peekCount === 1) return 0 - koffi.encode(totalAvail, 'uint32', 0) - return 1 - }) - api.getLastError = vi.fn(() => 5) - api.terminateProcess = vi.fn(() => 0) - const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') }) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-3', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toBeInstanceOf(AggregateError) - const settledPeekCount = peekCount - await new Promise(resolve => setTimeout(resolve, 5)) - expect(peekCount).toBe(settledPeekCount) - expect(waitForSingleObject).not.toHaveBeenCalled() - expect(closeHandle).toHaveBeenCalled() - }) - - it('pipe spawn aggregates process-handle closure failure after termination failure', async () => { - const { api } = state.stubs as HappyStubs - api.getLastError = vi.fn(() => 5) - api.terminateProcess = vi.fn(() => 0) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-4', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - api.closeHandle = vi.fn(() => 0) - const settlement = child.wait() - await expect(settlement).rejects.toBeInstanceOf(AggregateError) - const failure = await settlement.catch((error: unknown): unknown => error) - if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') - const errors = failure.errors as unknown[] - const apis = errors - .filter((error): error is Win32Error => error instanceof Win32Error) - .map(error => error.api) - expect(apis.filter(api => api === 'PeekNamedPipe')).toHaveLength(2) - expect(apis).toEqual(expect.arrayContaining(['CloseHandle', 'TerminateProcess'])) - }) }) describe('AclSandbox dispose', () => { diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 62da073509..32344c63b8 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 packages/subprocess/README.md -README.md: 56d6c04af92fa07673e3f8881bf20e47358fd001 -README.zh.md: 8b7db95e0196dbc98a67657478e4e242b4f7bca9 +README.md: 790db2c3c82fc9e359cae6a0ff1eab156b2776b5 +README.zh.md: e6ac837e0c0408720d46609edf154d423a96c11b diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 56d6c04af9..790db2c3c8 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,7 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | -| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, atomic Job attachment, waits, and handle cleanup | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, suspended Job assignment, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 8b7db95e01..e6ac837e0c 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | -| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、原子 Job 附加、wait 与句柄清理的唯一 Koffi owner | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、suspended Job 分配、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 24f7b1f607..d9bb79be51 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 3e82c10b7894b15d970b794429c69c6923632bb9 -README.zh.md: b1afc1a7222189329cbd84d9729fbafc3ecd3acb +README.md: 0005416bdfac6101090a3dc87defd71e15ec7537 +README.zh.md: 2c505ea5a1ec2fe2a930eca035b8a64ca3d4ba4f diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 3e82c10b78..0005416bdf 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -6,11 +6,11 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. -- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, and attaches that Job through `STARTUPINFOEXW` while creating the restricted child. The child is Job-owned before any user code can run; attribute setup or creation failure closes every owned resource, and no successful process creation can leave an unowned child. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining, accepts cancellation that stops polling, and frees its allocation before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. The sandbox retains its existing scheduling, result composition, and caller-owned Job closure. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. @@ -36,4 +36,5 @@ The package contributes no stable request prefix, so it does not invalidate mode - **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. - **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process. - **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. +- **Create-to-assignment interruption** — the target starts suspended and cannot execute before Job assignment, but an external termination of the runner in the narrow interval between process creation and assignment can leave the suspended target behind. The package does not claim atomic Job attachment. - **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index b1afc1a722..2c505ea5a1 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -6,11 +6,11 @@ ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 -- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,并在创建 restricted child 时通过 `STARTUPINFOEXW` 附加该 Job。child 会在任何用户代码运行前归属 Job;attribute 设置或创建失败都会关闭全部已拥有资源,成功创建进程后不会留下无 owner 的 child。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,接受停止轮询的取消信号,并在关闭管道读取句柄前释放原生分配;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。sandbox 保留既有调度、result 组合与调用方拥有的 Job 关闭行为。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 @@ -36,4 +36,5 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 - **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 - **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW` 以 `ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 - **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 +- **创建到分配之间的中断** — 目标以 suspended 状态启动,不能在 Job 分配前执行,但 runner 若在进程创建到分配之间的极窄区间被外力终止,可能留下 suspended target。本包不声明原子 Job 附加保证。 - **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 9409e9025b..fbdda9059f 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,10 +6,8 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */ -export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 -/** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */ -export const PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D +/** CreateProcess flag that prevents user code from running before resume. */ +export const CREATE_SUSPENDED = 0x4 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -36,9 +34,5 @@ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 /** x64 STARTUPINFOW byte size verified by the native probe. */ export const STARTUPINFOW_SIZE = 104 -/** x64 STARTUPINFOEXW byte size verified by the native probe. */ -export const STARTUPINFOEXW_SIZE = 112 -/** x64 pointer and HANDLE byte size. */ -export const POINTER_SIZE = 8 /** x64 PROCESS_INFORMATION byte size verified by the native probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 4abea75c5e..a2171d0023 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -81,22 +81,6 @@ export interface Win32ProcessBindings { startupInfo: NativePtr, processInfo: NativePtr, ): number - initializeProcThreadAttributeList( - attributeList: Buffer | null, - attributeCount: number, - flags: number, - size: NativePtr, - ): number - updateProcThreadAttribute( - attributeList: Buffer, - flags: number, - attribute: number, - value: NativePtr, - size: number, - previousValue: null, - returnSize: null, - ): number - deleteProcThreadAttributeList(attributeList: Buffer): void readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, @@ -110,6 +94,8 @@ export interface Win32ProcessBindings { getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + resumeThread(thread: NativePtr): number terminateProcess(process: NativePtr, exitCode: number): number getStdHandle(stdHandle: number): NativePtr } @@ -255,13 +241,6 @@ function bindings(): Win32ProcessBindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), - initializeProcThreadAttributeList: bind(kernel32, 'InitializeProcThreadAttributeList', 'int', [ - PVOID, 'uint32', 'uint32', koffi.pointer('size_t'), - ]), - updateProcThreadAttribute: bind(kernel32, 'UpdateProcThreadAttribute', 'int', [ - PVOID, 'uint32', 'size_t', PVOID, 'size_t', PVOID, PVOID, - ]), - deleteProcThreadAttributeList: bind(kernel32, 'DeleteProcThreadAttributeList', 'void', [PVOID]), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), @@ -270,6 +249,8 @@ function bindings(): Win32ProcessBindings { getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32ProcessBindings diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index fa7f2dd992..d6dee59d58 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -17,7 +17,6 @@ export type { Win32ProcessBindings, } from './ffi.ts' export { - closeHandleChecked, drainPipe, spawnInheritedJobProcess, spawnPipedProcess, diff --git a/packages/subprocess/win32-process/src/job-attribute.ts b/packages/subprocess/win32-process/src/job-attribute.ts deleted file mode 100644 index 7798cc6eea..0000000000 --- a/packages/subprocess/win32-process/src/job-attribute.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** Package-private STARTUPINFOEXW ownership for atomic Job attachment. */ - -import koffi from 'koffi' -import * as abi from './abi.ts' -import { STARTUPINFOW, throwWin32 } from './ffi.ts' -import type { NativePtr, StartupInfoInput, Win32ProcessBindings } from './ffi.ts' - -type Ptr = ReturnType -const PVOID: Ptr = koffi.pointer('void') - -const STARTUPINFOEXW = koffi.struct('DSH_STARTUPINFOEXW', { - StartupInfo: STARTUPINFOW, - lpAttributeList: PVOID, -}) - -/* v8 ignore start -- the native header probe pins this x64 layout. */ -if (STARTUPINFOEXW.size !== abi.STARTUPINFOEXW_SIZE) { - throw new Error(`STARTUPINFOEXW layout mismatch: koffi computed ${STARTUPINFOEXW.size}, expected ${abi.STARTUPINFOEXW_SIZE}`) -} -/* v8 ignore stop */ - -/** One extended startup record whose attribute list remains valid through CreateProcess. */ -export interface JobStartupInfo { - /** STARTUPINFOEXW pointer passed to CreateProcessAsUserW. */ - readonly pointer: NativePtr - /** Release the initialized process attribute list after CreateProcessAsUserW returns. */ - dispose(): void -} - -function queryAttributeListSize(api: Win32ProcessBindings): number { - const sizeSlot = koffi.alloc('size_t', 1) as NativePtr - try { - api.initializeProcThreadAttributeList(null, 1, 0, sizeSlot) - const attributeBytes = koffi.decode(sizeSlot, 'size_t') as number - if (attributeBytes === 0) { - throwWin32( - api, - 'InitializeProcThreadAttributeList', - api.getLastError(), - 'process-attribute size query', - ) - } - return attributeBytes - } finally { - koffi.free(sizeSlot) - } -} - -/** - * Build a STARTUPINFOEXW that assigns the restricted child to `job` during creation. - * @param api - active binding table. - * @param fields - inherited stdio fields for the nested STARTUPINFOW. - * @param job - caller-owned Job attached before any child thread exists. - * @returns extended startup pointer and its post-CreateProcess disposer. - */ -export function createJobStartupInfo( - api: Win32ProcessBindings, - fields: Omit, - job: NativePtr, -): JobStartupInfo { - const attributeList = Buffer.alloc(queryAttributeListSize(api)) - const sizeSlot = koffi.alloc('size_t', 1) as NativePtr - let initialized = false - let jobList: NativePtr | undefined - try { - koffi.encode(sizeSlot, 'size_t', attributeList.length) - if (api.initializeProcThreadAttributeList(attributeList, 1, 0, sizeSlot) === 0) { - throwWin32( - api, - 'InitializeProcThreadAttributeList', - api.getLastError(), - 'process-attribute initialization', - ) - } - initialized = true - jobList = koffi.alloc(PVOID, 1) as NativePtr - koffi.encode(jobList, PVOID, job) - if (api.updateProcThreadAttribute( - attributeList, - 0, - abi.PROC_THREAD_ATTRIBUTE_JOB_LIST, - jobList, - abi.POINTER_SIZE, - null, - null, - ) === 0) { - throwWin32( - api, - 'UpdateProcThreadAttribute', - api.getLastError(), - 'PROC_THREAD_ATTRIBUTE_JOB_LIST', - ) - } - const pointer = koffi.alloc(STARTUPINFOEXW, 1) as NativePtr - try { - koffi.encode(pointer, STARTUPINFOEXW, { - StartupInfo: { ...fields, cb: abi.STARTUPINFOEXW_SIZE }, - lpAttributeList: attributeList, - }) - } catch (error) { - /* v8 ignore start -- staging a STARTUPINFOEXW encode failure requires replacing Koffi's encoder. */ - koffi.free(pointer) - throw error - /* v8 ignore stop */ - } - return { - pointer, - dispose: () => { - try { - api.deleteProcThreadAttributeList(attributeList) - } finally { - koffi.free(jobList) - koffi.free(pointer) - } - }, - } - } catch (error) { - if (initialized) api.deleteProcThreadAttributeList(attributeList) - if (jobList !== undefined) koffi.free(jobList) - throw error - } finally { - koffi.free(sizeSlot) - } -} diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 4b948e1849..7c676e1f7f 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -15,7 +15,6 @@ import { throwLastError, throwWin32, } from './ffi.ts' -import { createJobStartupInfo } from './job-attribute.ts' import type { NativePtr, Win32ProcessBindings } from './ffi.ts' /** @@ -78,7 +77,7 @@ export interface SpawnedPipedProcess { stderrRead: NativePtr } -/** Child atomically attached to one caller-owned kill-on-close Job during creation. */ +/** Suspended child assigned to one caller-owned kill-on-close Job before resume. */ export interface SpawnedJobProcess { /** Direct child process id. */ pid: number @@ -240,21 +239,18 @@ export function spawnPipedProcess( * Drain one anonymous pipe until the writer closes it. * @param api - active binding table. * @param handle - caller-owned pipe read end. - * @param signal - optional cancellation that stops polling and closes the read end. * @returns complete bytes read before EOF; the handle is always closed. - * @throws when the drain is cancelled or a Win32 pipe operation fails. + * @throws when a Win32 pipe operation fails. */ export async function drainPipe( api: Win32ProcessBindings, handle: NativePtr, - signal?: AbortSignal, ): Promise { const chunks: Buffer[] = [] let countSlot: NativePtr | undefined try { countSlot = allocUint32() for (;;) { - signal?.throwIfAborted() const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) if (peeked === 0) { const win32Code = api.getLastError() @@ -321,10 +317,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { } /** - * Spawn atomically attached to a kill-on-close Job. + * Spawn suspended, assign the child to a kill-on-close Job, then resume it. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. - * @returns caller-owned process and Job handles after successful creation. + * @returns caller-owned process and Job handles after successful resume. * @remarks Node clears stdio handle inheritability at startup through * uv_disable_stdio_inheritance. This operation temporarily restores the bits * required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort: @@ -346,6 +342,7 @@ export function spawnInheritedJobProcess( const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] + let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined let created = 0 let createFailureCode = 0 @@ -360,31 +357,30 @@ export function spawnInheritedJobProcess( } enabled.push(handle) } - const startupInfo = createJobStartupInfo(api, { + startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, dwFlags: abi.STARTF_USESTDHANDLES, hStdInput: stdIn, hStdOutput: stdOut, hStdError: stdErr, - }, job) - try { - processInfo = allocProcessInfo() - created = createRestrictedProcess( - api, - options, - buildCommandLine(options.command, options.args), - abi.EXTENDED_STARTUPINFO_PRESENT, - startupInfo.pointer, - processInfo, - ) - if (created === 0) createFailureCode = api.getLastError() - } finally { - startupInfo.dispose() - } + }) + processInfo = allocProcessInfo() + created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + ) + if (created === 0) createFailureCode = api.getLastError() } catch (error) { freeNative(processInfo) api.closeHandle(job) throw error } finally { + freeNative(startupInfo) for (const handle of enabled) { // The runner spawns nothing else; cleanup failure must not mask the child. api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) @@ -407,25 +403,27 @@ export function spawnInheritedJobProcess( freeNative(processInfo) } if (info.hProcess === null || info.hThread === null) { + if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } + if (api.assignProcessToJobObject(job, info.hProcess) === 0) { + const win32Code = api.getLastError() + api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { + const win32Code = api.getLastError() + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } closeBestEffort(api, info.hThread) return { pid: info.dwProcessId, process: info.hProcess, job } } - -/** - * Close a handle and surface a failure without losing its operation label. - * @param api - active binding table. - * @param handle - caller-owned handle to close. - * @param detail - lifecycle label included in a failure. - */ -export function closeHandleChecked( - api: Win32ProcessBindings, - handle: NativePtr, - detail: string, -): void { - if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', detail) -} diff --git a/packages/subprocess/win32-process/tests/job-attribute.spec.ts b/packages/subprocess/win32-process/tests/job-attribute.spec.ts deleted file mode 100644 index 793cb62bdd..0000000000 --- a/packages/subprocess/win32-process/tests/job-attribute.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import koffi from 'koffi' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createJobStartupInfo } from '../src/job-attribute.ts' -import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function bindings(): { - api: Win32ProcessBindings - deleteProcThreadAttributeList: ReturnType -} { - const deleteProcThreadAttributeList = vi.fn() - const api = { - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList, - getLastError: vi.fn(() => 5), - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - return { api, deleteProcThreadAttributeList } -} - -const fields = { - dwFlags: 0x100, - hStdInput: 1n as NativePtr, - hStdOutput: 2n as NativePtr, - hStdError: 3n as NativePtr, -} - -describe('createJobStartupInfo allocation cleanup', () => { - it('frees the size slot when attribute-list buffer allocation throws', () => { - const { api, deleteProcThreadAttributeList } = bindings() - const free = vi.spyOn(koffi, 'free') - vi.spyOn(Buffer, 'alloc').mockImplementationOnce(() => { throw new Error('buffer allocation failed') }) - expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('buffer allocation failed') - expect(free).toHaveBeenCalledOnce() - expect(deleteProcThreadAttributeList).not.toHaveBeenCalled() - }) - - it('deletes the initialized list and frees the Job value when attachment fails', () => { - const { api, deleteProcThreadAttributeList } = bindings() - api.updateProcThreadAttribute = vi.fn(() => 0) - const free = vi.spyOn(koffi, 'free') - expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('PROC_THREAD_ATTRIBUTE_JOB_LIST') - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - expect(free).toHaveBeenCalledTimes(3) - }) - - it('frees every native allocation after the caller disposes the startup record', () => { - const { api, deleteProcThreadAttributeList } = bindings() - const free = vi.spyOn(koffi, 'free') - const startup = createJobStartupInfo(api, fields, 50n as NativePtr) - startup.dispose() - expect(free).toHaveBeenCalledTimes(4) - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts index f9e115e8d0..fe3c603c02 100644 --- a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -20,21 +20,11 @@ afterEach(() => { describe('spawnInheritedJobProcess allocation cleanup', () => { it('frees startup info when process-info allocation throws', () => { - const deleteProcThreadAttributeList = vi.fn() const api = { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList, closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -47,8 +37,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toThrow('process-info allocation failed') - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - expect(free).toHaveBeenCalledTimes(4) + expect(free).toHaveBeenCalledOnce() }) it('frees process info after a successful inherited spawn', () => { @@ -57,15 +46,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList: vi.fn(), createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -75,6 +55,8 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { }) return 1 }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -86,7 +68,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(free).toHaveBeenCalledTimes(5) + expect(free).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 93a501c197..2f7f021348 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -21,23 +21,6 @@ import { PROCESS_INFORMATION } from '../src/ffi.ts' const PVOID = koffi.pointer('void') -function jobAttributeStubs(): Pick< - Win32ProcessBindings, - 'initializeProcThreadAttributeList' | 'updateProcThreadAttribute' | 'deleteProcThreadAttributeList' -> { - return { - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList: vi.fn(), - } -} - /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType } { const closed: bigint[] = [] @@ -205,7 +188,9 @@ describe('spawnInheritedJobProcess failure paths', () => { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) return 1 }), - ...jobAttributeStubs(), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), + terminateProcess: vi.fn(() => 1), getLastError: vi.fn(() => 5), closeHandle, formatMessageW: vi.fn(() => 0), @@ -267,34 +252,11 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(100n) }) - it('closes the job when the attribute-list size query returns no size', () => { + it('terminates the suspended child before closing handles when Job assignment fails', () => { + const terminateProcess = vi.fn(() => 1) const { api, closeHandle } = inheritedApi({ - initializeProcThreadAttributeList: vi.fn(() => 0), - }) - expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) - .toThrow(Win32Error) - expect(closeHandle).toHaveBeenCalledWith(100n) - }) - - it('closes the job when attribute-list initialization fails', () => { - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 0 - }) - const { api, closeHandle } = inheritedApi({ initializeProcThreadAttributeList }) - expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) - .toThrow(Win32Error) - expect(closeHandle).toHaveBeenCalledWith(100n) - }) - - it('deletes the attribute list and closes the job when atomic Job attachment fails', () => { - const deleteProcThreadAttributeList = vi.fn() - const { api, closeHandle } = inheritedApi({ - updateProcThreadAttribute: vi.fn(() => 0), - deleteProcThreadAttributeList, + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, }) let caught: unknown try { @@ -302,8 +264,24 @@ describe('spawnInheritedJobProcess failure paths', () => { } catch (error) { caught = error } - expect(caught).toMatchObject({ api: 'UpdateProcThreadAttribute', win32Code: 5 }) - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 }) + expect(terminateProcess).toHaveBeenCalledWith(200n, 1) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the assigned child and Job when ResumeThread fails', () => { + const { api, closeHandle } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'ResumeThread', win32Code: 5 }) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) expect(closeHandle).toHaveBeenCalledWith(100n) }) @@ -342,6 +320,8 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).not.toHaveBeenCalledWith(200n) expect(closeHandle).not.toHaveBeenCalledWith(100n) + expect(api.assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n) + expect(api.resumeThread).toHaveBeenCalledWith(201n) }) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index e2f151c8e1..3fbaa570fe 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -2,16 +2,11 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { Win32Error, - closeHandleChecked, drainPipe, spawnInheritedJobProcess, spawnPipedProcess, } from '../src/index.ts' -import { - EXTENDED_STARTUPINFO_PRESENT, - POINTER_SIZE, - PROC_THREAD_ATTRIBUTE_JOB_LIST, -} from '../src/abi.ts' +import { CREATE_SUSPENDED } from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -21,12 +16,10 @@ function inheritedApi(overrides: Partial = {}): { api: Win32ProcessBindings events: string[] createProcessAsUserW: ReturnType - initializeProcThreadAttributeList: ReturnType - updateProcThreadAttribute: ReturnType - attachedJob: () => NativePtr | null + assignProcessToJobObject: ReturnType + resumeThread: ReturnType } { const events: string[] = [] - let attachedJob: NativePtr | null = null const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = overrides.createProcessAsUserW ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { @@ -40,20 +33,12 @@ function inheritedApi(overrides: Partial = {}): { return 1 }) const createProcessAsUserW = vi.fn(createProcessAsUserWImpl) - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - events.push('attribute-size') - koffi.encode(size, 'size_t', 64) - return 0 - } - events.push('attribute-init') + const assignProcessToJobObject = vi.fn(() => { + events.push('assign') return 1 }) - const updateProcThreadAttribute = vi.fn((_list, _flags, attribute: number, value: NativePtr) => { - if (attribute === PROC_THREAD_ATTRIBUTE_JOB_LIST) { - attachedJob = koffi.decode(value, PVOID) as NativePtr - events.push('attach-job') - } + const resumeThread = vi.fn(() => { + events.push('resume') return 1 }) const api = { @@ -64,9 +49,8 @@ function inheritedApi(overrides: Partial = {}): { events.push(flags === 0 ? 'restore' : 'inherit') return 1 }), - initializeProcThreadAttributeList, - updateProcThreadAttribute, - deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }), + assignProcessToJobObject, + resumeThread, terminateProcess: vi.fn(() => 1), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), getLastError: vi.fn(() => 5), @@ -78,23 +62,21 @@ function inheritedApi(overrides: Partial = {}): { api, events, createProcessAsUserW, - initializeProcThreadAttributeList, - updateProcThreadAttribute, - attachedJob: () => attachedJob, + assignProcessToJobObject, + resumeThread, } } describe('spawnInheritedJobProcess', () => { const token = 70n as NativePtr - it('attaches a restricted child to the Job inside CreateProcessAsUserW', () => { + it('creates suspended, assigns the Job, then resumes the restricted child', () => { const { api, events, createProcessAsUserW, - initializeProcThreadAttributeList, - updateProcThreadAttribute, - attachedJob, + assignProcessToJobObject, + resumeThread, } = inheritedApi() const child = spawnInheritedJobProcess(api, { command: 'cmd.exe', @@ -103,20 +85,10 @@ describe('spawnInheritedJobProcess', () => { token, }) expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create')) - expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create')) - expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything()) - expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything()) - expect(updateProcThreadAttribute).toHaveBeenCalledWith( - expect.any(Buffer), - 0, - PROC_THREAD_ATTRIBUTE_JOB_LIST, - expect.anything(), - POINTER_SIZE, - null, - null, - ) - expect(attachedJob()).toBe(50n) + expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign')) + expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume')) + expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n) + expect(resumeThread).toHaveBeenCalledWith(61n) expect(createProcessAsUserW).toHaveBeenCalledWith( token, null, @@ -124,7 +96,7 @@ describe('spawnInheritedJobProcess', () => { null, null, 1, - EXTENDED_STARTUPINFO_PRESENT, + CREATE_SUSPENDED, null, 'C:\\work', expect.anything(), @@ -187,10 +159,12 @@ describe('spawnInheritedJobProcess', () => { expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 }) }) - it('closes the atomic Job when CreateProcessAsUserW returns a null thread handle', () => { + it('terminates the suspended process and closes the Job when CreateProcessAsUserW returns a null thread handle', () => { const closeHandle = vi.fn(() => 1) + const terminateProcess = vi.fn(() => 1) const { api } = inheritedApi({ closeHandle, + terminateProcess, createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -207,9 +181,43 @@ describe('spawnInheritedJobProcess', () => { cwd: 'C:\\work', token, })).toThrow('null process/thread handles') + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) expect(closeHandle).toHaveBeenCalledWith(50n) expect(closeHandle).toHaveBeenCalledWith(60n) }) + + it('terminates the suspended child before closing handles when Job assignment fails', () => { + const closeHandle = vi.fn(() => 1) + const terminateProcess = vi.fn(() => 1) + const { api } = inheritedApi({ + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, + closeHandle, + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) + }) + + it('closes the assigned Job and process when ResumeThread fails', () => { + const closeHandle = vi.fn(() => 1) + const { api } = inheritedApi({ + resumeThread: vi.fn(() => 0xFFFFFFFF), + closeHandle, + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) + }) }) describe('wait and pipe cleanup', () => { @@ -234,39 +242,6 @@ describe('wait and pipe cleanup', () => { expect(closeHandle).toHaveBeenCalledWith(80n) }) - it('stops polling and closes the read end when cancelled', async () => { - const controller = new AbortController() - const closeHandle = vi.fn(() => 1) - const peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, available) => { - koffi.encode(available, 'uint32', 0) - return 1 - }) - const api = { - peekNamedPipe, - closeHandle, - } as unknown as Win32ProcessBindings - const draining = drainPipe(api, 80n as NativePtr, controller.signal) - const cancellation = new Error('stop pipe drain') - controller.abort(cancellation) - await expect(draining).rejects.toBe(cancellation) - expect(peekNamedPipe).toHaveBeenCalledOnce() - expect(closeHandle).toHaveBeenCalledWith(80n) - }) - - it('checks caller-owned handle closure', () => { - const closeHandle = vi.fn(() => 1) - const api = { closeHandle } as unknown as Win32ProcessBindings - expect(() => { closeHandleChecked(api, 80n as NativePtr, 'sandbox Job') }).not.toThrow() - expect(closeHandle).toHaveBeenCalledWith(80n) - - const failing = { - closeHandle: vi.fn(() => 0), - getLastError: vi.fn(() => 6), - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - expect(() => { closeHandleChecked(failing, 81n as NativePtr, 'sandbox Job') }).toThrow(Win32Error) - }) - it('terminates a piped child when CreateProcess returns a null thread handle', () => { let nextPipe = 10n const terminateProcess = vi.fn(() => 1) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 50452bd514..1c9480105d 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -13,14 +13,11 @@ int wmain() P(offsetof(STARTUPINFOW, hStdInput)); P(offsetof(STARTUPINFOW, hStdOutput)); P(offsetof(STARTUPINFOW, hStdError)); - P(sizeof(STARTUPINFOEXW)); - P(offsetof(STARTUPINFOEXW, lpAttributeList)); P(sizeof(PROCESS_INFORMATION)); P(offsetof(PROCESS_INFORMATION, hProcess)); P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); - P(EXTENDED_STARTUPINFO_PRESENT); - P(PROC_THREAD_ATTRIBUTE_JOB_LIST); + P(CREATE_SUSPENDED); P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); @@ -38,11 +35,8 @@ int wmain() P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); - static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size"); - static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag"); - static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute"); + static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index e4ab756424..cc43e06f55 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -49,8 +49,8 @@ describe('CI workflow', () => { const node24Coverage = workflow.jobs['node-24-coverage'] const node24Consumers = workflow.jobs['node-24-consumers'] const aggregate = workflow.jobs['all-checks-passed'] - if (!Array.isArray(windows.steps) || !Array.isArray(serialWindows.steps) || !Array.isArray(aggregate.needs)) { - throw new TypeError('Windows jobs must define steps and the aggregate must define needs') + if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows job must define steps and the aggregate must define needs') } const commandSteps = windows.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' @@ -78,7 +78,6 @@ describe('CI workflow', () => { const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) - expect(nativeCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') // wine-apt-cache: master-only, seeds the Wine apt cache. @@ -89,16 +88,6 @@ describe('CI workflow', () => { expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') - const serialWindowsCommandSteps = serialWindows.steps.filter((step): step is Record & { run: string } => ( - isRecord(step) && typeof step.run === 'string' - )) - expect(serialWindowsCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') - expect(serialWindowsCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - const abiProbeScript = readFileSync(resolve(root, 'scripts/verify-win32-abi.ps1'), 'utf8') - expect(abiProbeScript).toContain('vswhere.exe') - expect(abiProbeScript).toContain('vcvars64.bat') - expect(abiProbeScript).toContain('packages/subprocess/win32-process/verify/abi-probe.cpp') - expect(abiProbeScript).toContain('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') diff --git a/scripts/verify-win32-abi.ps1 b/scripts/verify-win32-abi.ps1 deleted file mode 100644 index c0125c9eab..0000000000 --- a/scripts/verify-win32-abi.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } -$probeRoot = Join-Path $temporaryRoot 'dsh-win32-abi-probes' -New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null - -$vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' -if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } -$vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() -if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } -$vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' -if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } - -$processProbe = Join-Path $probeRoot 'win32-process.exe' -$processObject = Join-Path $probeRoot 'win32-process.obj' -$processSource = Join-Path $repoRoot 'packages/subprocess/win32-process/verify/abi-probe.cpp' -$sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' -$sandboxObject = Join-Path $probeRoot 'sandbox-windows-acl.obj' -$sandboxSource = Join-Path $repoRoot 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' - -$probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$processObject`" /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$sandboxObject`" /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" -& cmd.exe /d /s /c $probeCommand -if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } From 19256704c7bb72f537a74390625ec5a58707298e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 15:25:02 +0800 Subject: [PATCH 023/248] test(win32-process): type handle-order assertions --- packages/subprocess/win32-process/tests/process.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index 3fbaa570fe..263908082b 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -106,7 +106,7 @@ describe('spawnInheritedJobProcess', () => { it('restores already-enabled stdio and closes the Job when inheritance setup fails', () => { let calls = 0 - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const setHandleInformation = vi.fn((_handle: NativePtr, _mask: number, flags: number) => { if (flags === 0) return 1 calls += 1 @@ -160,7 +160,7 @@ describe('spawnInheritedJobProcess', () => { }) it('terminates the suspended process and closes the Job when CreateProcessAsUserW returns a null thread handle', () => { - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const terminateProcess = vi.fn(() => 1) const { api } = inheritedApi({ closeHandle, @@ -187,7 +187,7 @@ describe('spawnInheritedJobProcess', () => { }) it('terminates the suspended child before closing handles when Job assignment fails', () => { - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const terminateProcess = vi.fn(() => 1) const { api } = inheritedApi({ assignProcessToJobObject: vi.fn(() => 0), @@ -205,7 +205,7 @@ describe('spawnInheritedJobProcess', () => { }) it('closes the assigned Job and process when ResumeThread fails', () => { - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const { api } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF), closeHandle, From 7ef1c458f062732a5e98f0b3b107b9a38c4a8dc9 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 16:03:15 +0800 Subject: [PATCH 024/248] fix(win32-process): close PR1 validation gaps --- .../sandbox/sandbox-windows-acl/src/index.ts | 2 -- .../sandbox-windows-acl/tests/ffi.spec.ts | 2 +- .../subprocess/win32-process/package.json | 2 +- .../tests/process-failure-paths.spec.ts | 14 +++++--- .../win32-process/tests/process.spec.ts | 32 ------------------- 5 files changed, 11 insertions(+), 41 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 9e4568c726..5ebdccbd6a 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -55,8 +55,6 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' -export { Win32Error } from '@deepseek-ai/dsh-win32-process' - /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 761598a073..760370b24f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { Win32Error } from '../src/index.ts' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { allocBytes, decodePtrAt, getTempPath, isInvalidHandle, sameSidAt, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 7d6257d692..ac106cc927 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 2f7f021348..41ba438516 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -254,7 +254,7 @@ describe('spawnInheritedJobProcess failure paths', () => { it('terminates the suspended child before closing handles when Job assignment fails', () => { const terminateProcess = vi.fn(() => 1) - const { api, closeHandle } = inheritedApi({ + const { api, closeHandle, closed } = inheritedApi({ assignProcessToJobObject: vi.fn(() => 0), terminateProcess, }) @@ -269,10 +269,11 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).toHaveBeenCalledWith(200n) expect(closeHandle).toHaveBeenCalledWith(100n) + expect(closed).toEqual([201n, 200n, 100n]) }) it('closes the assigned child and Job when ResumeThread fails', () => { - const { api, closeHandle } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) }) + const { api, closeHandle, closed } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) }) let caught: unknown try { spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) @@ -283,6 +284,7 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).toHaveBeenCalledWith(200n) expect(closeHandle).toHaveBeenCalledWith(100n) + expect(closed).toEqual([201n, 200n, 100n]) }) it('closes the job and reports when SetInformationJobObject fails', () => { @@ -311,7 +313,9 @@ describe('spawnInheritedJobProcess failure paths', () => { }) it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => { - const { api, closeHandle } = inheritedApi() + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const { api, closeHandle } = inheritedApi({ assignProcessToJobObject, resumeThread }) const spawned = spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) expect(spawned.pid).toBe(1234) expect(spawned.process).toBe(200n) @@ -320,8 +324,8 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).not.toHaveBeenCalledWith(200n) expect(closeHandle).not.toHaveBeenCalledWith(100n) - expect(api.assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n) - expect(api.resumeThread).toHaveBeenCalledWith(201n) + expect(assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n) + expect(resumeThread).toHaveBeenCalledWith(201n) }) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index 263908082b..85835a659c 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -186,38 +186,6 @@ describe('spawnInheritedJobProcess', () => { expect(closeHandle).toHaveBeenCalledWith(60n) }) - it('terminates the suspended child before closing handles when Job assignment fails', () => { - const closeHandle = vi.fn((_handle: NativePtr) => 1) - const terminateProcess = vi.fn(() => 1) - const { api } = inheritedApi({ - assignProcessToJobObject: vi.fn(() => 0), - terminateProcess, - closeHandle, - }) - expect(() => spawnInheritedJobProcess(api, { - command: 'cmd.exe', - args: [], - cwd: 'C:\\work', - token, - })).toThrow(Win32Error) - expect(terminateProcess).toHaveBeenCalledWith(60n, 1) - expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) - }) - - it('closes the assigned Job and process when ResumeThread fails', () => { - const closeHandle = vi.fn((_handle: NativePtr) => 1) - const { api } = inheritedApi({ - resumeThread: vi.fn(() => 0xFFFFFFFF), - closeHandle, - }) - expect(() => spawnInheritedJobProcess(api, { - command: 'cmd.exe', - args: [], - cwd: 'C:\\work', - token, - })).toThrow(Win32Error) - expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) - }) }) describe('wait and pipe cleanup', () => { From ff7a5a042c5ee0f77550954fe1717b0e06a396cd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 19:17:03 +0800 Subject: [PATCH 025/248] docs(win32-process): point ABI verification to its owner --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 +- packages/subprocess/win32-process/README.i18n.yaml | 4 ++-- packages/subprocess/win32-process/README.md | 10 ++++++++++ packages/subprocess/win32-process/README.zh.md | 10 ++++++++++ 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ace32ae8cf..10be54b7bb 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.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 packages/sandbox/sandbox-windows-acl/README.md -README.md: c31f6452815c5629b49c302ebec408da1f0f4803 -README.zh.md: c6a87075875d3424b47121e32d8465a752149c89 +README.md: 91172cc0b2fcab1daceb75f7c02f3eecc679bab1 +README.zh.md: 0e8435e3d1a27a2868f97d6af4ce9e66953e8a26 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index c31f645281..91172cc0b2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -62,7 +62,7 @@ The `AclSandbox` class (explicit private `tempDir` + `tempWriteSid`, or `tempDir ## Header verification -All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts): +The sandbox-owned SID, ACL, token, file, and lock constants and layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `fileapi.h`) and are cross-checked by [`verify/abi-probe.cpp`](verify/abi-probe.cpp). The shared process, stdio, and Job ABI is owned and verified by [`@deepseek-ai/dsh-win32-process`](../../subprocess/win32-process/README.md#header-verification). ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index c6a8707587..0e8435e3d1 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -64,7 +64,7 @@ Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安 ## 头部验证 -所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查: +sandbox 自有的 SID、ACL、token、文件与锁常量和布局均已在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `fileapi.h`)验证,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 交叉检查。共享的 process、stdio 与 Job ABI 由 [`@deepseek-ai/dsh-win32-process`](../../subprocess/win32-process/README.md#header-verification) 归属并验证。 ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index d9bb79be51..1dddea0df5 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 0005416bdfac6101090a3dc87defd71e15ec7537 -README.zh.md: 2c505ea5a1ec2fe2a930eca035b8a64ca3d4ba4f +README.md: fcc6ad9cb5ca99ac55c817ef796c20751efffacc +README.zh.md: fbf37823b409f743818b1425192070a5c90f3932 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 0005416bdf..fcc6ad9cb5 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -14,6 +14,16 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. +## Header verification + +The process, stdio, and Job constants, signatures, and layouts are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe +``` + +The Koffi struct definitions also assert their sizes at module load, so a header or layout mismatch fails before native process creation. + ## Model Experience ### Process primitives diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 2c505ea5a1..fbf37823b4 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -14,6 +14,16 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 +## 头部验证 + +process、stdio 与 Job 的常量、签名和布局由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe +``` + +Koffi 结构体定义还会在模块加载时断言自身大小,因此头文件或布局不匹配会在创建 native process 前失败。 + ## Model Experience ### 进程原语 From 458ba498151914fb5feba8d8491ac14918060b6b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 19:28:05 +0800 Subject: [PATCH 026/248] docs(win32-process): narrow ABI verification claims --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 -- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 -- packages/subprocess/win32-process/README.i18n.yaml | 4 ++-- packages/subprocess/win32-process/README.md | 4 ++-- packages/subprocess/win32-process/README.zh.md | 4 ++-- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 10be54b7bb..7a44094d6f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.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 packages/sandbox/sandbox-windows-acl/README.md -README.md: 91172cc0b2fcab1daceb75f7c02f3eecc679bab1 -README.zh.md: 0e8435e3d1a27a2868f97d6af4ce9e66953e8a26 +README.md: 2cf79c8eede0943630f79bea717c9e072226fa7f +README.zh.md: 690b5d10ecbeae0192dc98095c785ab64030e9f6 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 91172cc0b2..2cf79c8eed 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -68,8 +68,6 @@ The sandbox-owned SID, ACL, token, file, and lock constants and layouts were ver g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe ``` -The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory. - ## Verified boundaries (inherent to restricted tokens, not this port) - **Everyone grants remain ambient write authority.** Everyone must stay in both restricting lists: removing it breaks early DLL initialization and CNG. An external NTFS object whose normal DACL grants Everyone a requested write right therefore clears both access checks and stays writable under both modes. The real runner suite provisions an external `Everyone:Modify` directory and pins that behavior; the provider reports `enforcement: 'partial'` so callers can reject or surface the weaker boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 0e8435e3d1..690b5d10ec 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -70,8 +70,6 @@ sandbox 自有的 SID、ACL、token、文件与锁常量和布局均已在开发 g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe ``` -koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。 - ## 已验证边界(受限令牌固有,非本移植引入) - **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中:移除它会破坏早期 DLL 初始化与 CNG。因此,如果外部 NTFS 对象的正常 DACL 向 Everyone 授予所请求的写权限,它就会同时通过两次访问检查,并在两种模式下保持可写。真实 runner 套件配置一个外部 `Everyone:Modify` 目录并钉住该行为;提供方报告 `enforcement: 'partial'`,使调用方能够拒绝或向上暴露这项较弱的边界。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 1dddea0df5..c89fd17fb3 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: fcc6ad9cb5ca99ac55c817ef796c20751efffacc -README.zh.md: fbf37823b409f743818b1425192070a5c90f3932 +README.md: 208a31741098c5c4d76d846b3a1345b24b6fc135 +README.zh.md: e5da30f30c447c4f450af3062f1aa11675678394 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index fcc6ad9cb5..208a317410 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -16,13 +16,13 @@ The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child polic ## Header verification -The process, stdio, and Job constants, signatures, and layouts are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): +The process, stdio, and Job constants plus selected structure sizes and offsets are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -The Koffi struct definitions also assert their sizes at module load, so a header or layout mismatch fails before native process creation. +The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe remains the evidence for the other recorded offsets and constants. ## Model Experience diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index fbf37823b4..e5da30f30c 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -16,13 +16,13 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 ## 头部验证 -process、stdio 与 Job 的常量、签名和布局由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: +process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -Koffi 结构体定义还会在模块加载时断言自身大小,因此头文件或布局不匹配会在创建 native process 前失败。 +Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小;其余已记录偏移和常量由该探针提供证据。 ## Model Experience From a5368680ae957e2402aa06ba3d0189890a233841 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 19:53:56 +0800 Subject: [PATCH 027/248] docs(win32-process): keep localized header link valid --- packages/subprocess/win32-process/README.i18n.yaml | 2 +- packages/subprocess/win32-process/README.zh.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index c89fd17fb3..d5743ba165 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md README.md: 208a31741098c5c4d76d846b3a1345b24b6fc135 -README.zh.md: e5da30f30c447c4f450af3062f1aa11675678394 +README.zh.md: 3c403efa8cdf389539663e79d95b766fb8ba0fcf diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index e5da30f30c..3c403efa8c 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -14,6 +14,8 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 + + ## 头部验证 process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: From 85b8484a95cee01336c3d56c9bcca480b94e62f3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 20:13:01 +0800 Subject: [PATCH 028/248] test(sandbox): remove stale export assertion --- packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 760370b24f..5dab1058c2 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -75,12 +75,6 @@ describe('getTempPath', () => { }) }) -describe('public error export', () => { - it('keeps the sandbox Win32 error type', () => { - expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error) - }) -}) - describe('sandbox pointer handling', () => { it('isInvalidHandle treats NULL as failure', () => { expect(isInvalidHandle(null)).toBe(true) From 499c1262a222ef24006e434749d3db39669c82c2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 21 Aug 2026 08:56:19 +0800 Subject: [PATCH 029/248] ci(python): drop PR labeled trigger for python-release dry-run Remove the pull_request:[labeled] trigger from python-release.yml so the workflow no longer fires (and shows a gray skipped check) when a PR gets any non-dry-run label. The credential-free dry-run validation is now manual-only (workflow_dispatch with publish=false), preserving the validation capability without a PR gray segment. - python-release.yml: on is workflow_dispatch only; build.if is github.event_name == 'workflow_dispatch'. - ci-workflow.spec.ts: assert python-release has no pull_request event and the simplified build.if. - python/development.(md,zh.md) and 2026-08-11-python-publication-workflow note (en/zh/i18n): describe the manual dispatch-only dry-run path. Verification: ci-workflow.spec.ts 14/14, typecheck clean, note-format 585, verify-translation-pairing consistent. --- .../2026-08-11-python-publication-workflow.i18n.yaml | 4 ++-- .../2026-08-11-python-publication-workflow.md | 2 +- .../2026-08-11-python-publication-workflow.zh.md | 2 +- .github/workflows/python-release.yml | 12 +++++------- python/development.md | 2 +- python/development.zh.md | 2 +- scripts/ci-workflow.spec.ts | 5 ++--- 7 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml index d31b17817e..00f25fc5d6 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.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 .agents/notes/implemented/process/2026-08-11-python-publication-workflow.md -2026-08-11-python-publication-workflow.md: 870db08e1d59ad7840fa9acf822915f83ecbd31b -2026-08-11-python-publication-workflow.zh.md: 0b2b4a71b909a510bc5a7f52132dbb0ba2bf3e67 +2026-08-11-python-publication-workflow.md: 15900d70f5c78eea92e8bbe908f395f243bea527 +2026-08-11-python-publication-workflow.zh.md: 17b9b14dd16d85301796a38bb64c464c94a8ab9a diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md index 870db08e1d..15900d70f5 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md @@ -10,7 +10,7 @@ The Python SDK comprises one platform-independent client wheel and three native ## Decision -The `Release (Python)` GitHub workflow exposes credential-free validation to pull requests labeled `python-release-dry-run` and to manual runs with `publish=false`. Both paths call the native wheel builder for all three platforms, install the Linux release set on Python 3.10 and 3.14, download the four resulting artifacts, verify their exact filenames and package metadata, enforce PyPI's default per-file size limit, record SHA-256 hashes, and retain one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and pull request events cannot enter either publication job. +The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all three platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the four resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry-run run cannot enter either publication job. A run with `publish=true` must use the `python-v` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token. diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md index 0b2b4a71b9..17b9b14dd1 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md @@ -10,7 +10,7 @@ Python SDK 由一个平台无关的客户端 wheel 包和三个原生运行时 w ## 决策 -GitHub 的 `Release (Python)` 工作流为带有 `python-release-dry-run` 标签的拉取请求和设置 `publish=false` 的手动运行提供无凭据验证。两条路径都会为全部三个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得四份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,拉取请求事件无法进入任何发布作业。 +GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部三个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得四份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index f5b9c63c4b..d33ad71ba5 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -1,9 +1,9 @@ name: Release (Python) -# A PR labeled python-release-dry-run or a manual run with publish=false builds -# and validates the complete release without registry credentials. Publication -# is accepted only from a manual run on the matching python-v* tag when the -# private publisher-repository identity and public-PyPI switch are configured. +# A manual run with publish=false builds and validates the complete release +# without registry credentials. Publication is accepted only from a manual run +# on the matching python-v* tag when the private publisher-repository identity +# and public-PyPI switch are configured. on: workflow_dispatch: inputs: @@ -12,8 +12,6 @@ on: required: true type: boolean default: false - pull_request: - types: [labeled] permissions: contents: read @@ -27,7 +25,7 @@ concurrency: jobs: build: name: Build four wheels - if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'python-release-dry-run' + if: github.event_name == 'workflow_dispatch' uses: ./.github/workflows/build-exe-for-python-sdk.yml with: targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 diff --git a/python/development.md b/python/development.md index 617d030294..2c96a98b56 100644 --- a/python/development.md +++ b/python/development.md @@ -79,7 +79,7 @@ The runtime distribution is wheel-only. The release pipeline publishes three pla ## Validate a release candidate -Label a pull request `python-release-dry-run`, or manually run the GitHub `Release (Python)` workflow with `publish=false`, to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. Both paths have no registry credentials; a pull request run cannot enter either publication job. +Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry-run run cannot enter either publication job. Public publication runs from the private automation repository; package metadata points to the separate read-only public source mirror, which does not run release Actions. The private repository defines the repository variable `PYPI_PUBLISHER_REPOSITORY` as its own `owner/name` and keeps `PUBLIC_PYPI_RELEASE_ENABLED=false` except during an intentional release. diff --git a/python/development.zh.md b/python/development.zh.md index be2a6196ae..9eaab7c163 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -79,7 +79,7 @@ pip install \ ## 验证候选发行版 -为拉取请求添加 `python-release-dry-run` 标签,或手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部四个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。两条路径都没有注册表凭据,拉取请求运行无法进入任何发布作业。 +手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部四个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。该运行没有注册表凭据,dry-run 运行无法进入任何发布作业。 公开发布从私有自动化仓库运行;包元数据指向独立的只读公开源码镜像,该镜像不运行发布 Actions。私有仓库把仓库变量 `PYPI_PUBLISHER_REPOSITORY` 定义为自身的 `owner/name`,并且只在有意发布期间把 `PUBLIC_PYPI_RELEASE_ENABLED` 从 `false` 改为 `true`。 diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index ac4535bc1b..52fa275573 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -273,7 +273,6 @@ describe('Python release workflows', () => { it('keeps complete wheel validation separate from protected public publication', () => { const workflow = loadWorkflow('.github/workflows/python-release.yml') const dispatch = workflowEvent(workflow, 'workflow_dispatch') - const pullRequest = workflowEvent(workflow, 'pull_request') const build = workflowJob(workflow, 'build') const pythonCompat = workflowJob(workflow, 'python-compat') const validate = workflowJob(workflow, 'validate') @@ -289,9 +288,9 @@ describe('Python release workflows', () => { } expect(dispatch.inputs.publish).toMatchObject({ type: 'boolean', default: false }) - expect(pullRequest).toEqual({ types: ['labeled'] }) + expect(workflow.on).not.toHaveProperty('pull_request') expect(build).toMatchObject({ - if: "github.event_name == 'workflow_dispatch' || github.event.label.name == 'python-release-dry-run'", + if: "github.event_name == 'workflow_dispatch'", uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', From cb5b762922dc0d679fca80f038e7d5e1601e15c6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 21 Aug 2026 11:51:50 +0800 Subject: [PATCH 030/248] fix(docs): correct zh locale link in composer-edit-range note The static gate (translation pairing) failed on a pre-existing master note: 2026-08-20-composer-edit-range-from-selection.zh.md:17 linked the zh target with the en .md path. Point it at the .zh.md target and re-record the i18n hash. This unblocks the required node 24 / static gate (it is not part of the python-release gray-check change but sits on the same PR's CI path). --- .../2026-08-20-composer-edit-range-from-selection.i18n.yaml | 2 +- .../bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml index d84448b9d2..51652f6906 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md 2026-08-20-composer-edit-range-from-selection.md: f836fe4de297746d35f7343cd215e8522a0116d0 -2026-08-20-composer-edit-range-from-selection.zh.md: 56f7044ee7f60b72d32226451d663b3f18371c69 +2026-08-20-composer-edit-range-from-selection.zh.md: 86e65e4567c6d061b458e77be856aa1942cf0fe3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md index 56f7044ee7..86e65e4567 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md @@ -14,7 +14,7 @@ Status: implemented 此时草稿看上去仍然正确,却已不携带任何结构化引用,提交走的是无 occurrence 的那条路,把草稿原样发出。宿主收到的是给人看的标签而不是所有者的模型形式,什么也解析不出来。专为阻止这种降级而存在的序列化守卫从不运行,因为它只在还有 occurrence 需要序列化时才触发。 -这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。 +这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.zh.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。 ## 决策 From 374f3cdb0770a80a7ea99909ec4e4d3ff042ebf1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 21 Aug 2026 11:53:50 +0800 Subject: [PATCH 031/248] fix(cic): re-record development pair and tighten python-release spec assertion Address PR #2875 review: - Re-record python/development.i18n.yaml (corpus verify-translation-pairing was out of sync after editing development.md/zh.md) and the 2026-08-11 python-publication-workflow pair after the dry-run wording tweak. - Tighten the python-release spec assertion to the exact event set (['workflow_dispatch']) instead of not.toHaveProperty('pull_request'). - Fix the 'dry-run run' wording in development.md and the note. Corpus-wide verify-translation-pairing (1001 pairs) and note-format (594) pass; ci-workflow.spec.ts 14/14. --- .../process/2026-08-11-python-publication-workflow.i18n.yaml | 2 +- .../process/2026-08-11-python-publication-workflow.md | 2 +- python/development.i18n.yaml | 4 ++-- python/development.md | 2 +- scripts/ci-workflow.spec.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml index 00f25fc5d6..b454ac5682 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.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 .agents/notes/implemented/process/2026-08-11-python-publication-workflow.md -2026-08-11-python-publication-workflow.md: 15900d70f5c78eea92e8bbe908f395f243bea527 +2026-08-11-python-publication-workflow.md: db346dfb96d1657e732c72a3f7a3ca74f92a947a 2026-08-11-python-publication-workflow.zh.md: 17b9b14dd16d85301796a38bb64c464c94a8ab9a diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md index 15900d70f5..db346dfb96 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md @@ -10,7 +10,7 @@ The Python SDK comprises one platform-independent client wheel and three native ## Decision -The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all three platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the four resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry-run run cannot enter either publication job. +The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all three platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the four resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. A run with `publish=true` must use the `python-v` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token. diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 64a2cff09b..5165656123 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 617d030294dafa51aea513adb811bb5f377431c9 -development.zh.md: be2a6196aed13a4f748e1b178f34603bbd08e5ac +development.md: d684dafea21a8e71f7819279e5885b6e63b8f7f7 +development.zh.md: 9eaab7c1633870367d4de3b79cd630146d284ff2 diff --git a/python/development.md b/python/development.md index 2c96a98b56..d684dafea2 100644 --- a/python/development.md +++ b/python/development.md @@ -79,7 +79,7 @@ The runtime distribution is wheel-only. The release pipeline publishes three pla ## Validate a release candidate -Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry-run run cannot enter either publication job. +Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry run cannot enter either publication job. Public publication runs from the private automation repository; package metadata points to the separate read-only public source mirror, which does not run release Actions. The private repository defines the repository variable `PYPI_PUBLISHER_REPOSITORY` as its own `owner/name` and keeps `PUBLIC_PYPI_RELEASE_ENABLED=false` except during an intentional release. diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 52fa275573..d5c135c398 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -288,7 +288,7 @@ describe('Python release workflows', () => { } expect(dispatch.inputs.publish).toMatchObject({ type: 'boolean', default: false }) - expect(workflow.on).not.toHaveProperty('pull_request') + expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(build).toMatchObject({ if: "github.event_name == 'workflow_dispatch'", uses: './.github/workflows/build-exe-for-python-sdk.yml', From ae193bfc077f482faaf884694bad191076648b64 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 21 Aug 2026 11:54:36 +0800 Subject: [PATCH 032/248] fix(cic): narrow workflow.on before Object.keys in python-release assertion Guard workflow.on with isRecord before Object.keys to satisfy TS2769. --- scripts/ci-workflow.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index d5c135c398..b5497b0e9e 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -288,6 +288,7 @@ describe('Python release workflows', () => { } expect(dispatch.inputs.publish).toMatchObject({ type: 'boolean', default: false }) + if (!isRecord(workflow.on)) throw new TypeError('python-release workflow must define on') expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(build).toMatchObject({ if: "github.event_name == 'workflow_dispatch'", From 7214d0d9588fb0e6b9477dc063c530ecdcfc1c95 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 21 Aug 2026 11:55:57 +0800 Subject: [PATCH 033/248] refactor(python): drop now-always-true build.if python-release.yml only triggers on workflow_dispatch, so build.if: github.event_name == 'workflow_dispatch' is always true and redundant; remove it (the exact event set is already pinned in the spec). Update the spec assertion accordingly. --- .github/workflows/python-release.yml | 1 - scripts/ci-workflow.spec.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index d33ad71ba5..d888d17a8a 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -25,7 +25,6 @@ concurrency: jobs: build: name: Build four wheels - if: github.event_name == 'workflow_dispatch' uses: ./.github/workflows/build-exe-for-python-sdk.yml with: targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index b5497b0e9e..ed59846160 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -291,7 +291,6 @@ describe('Python release workflows', () => { if (!isRecord(workflow.on)) throw new TypeError('python-release workflow must define on') expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(build).toMatchObject({ - if: "github.event_name == 'workflow_dispatch'", uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', From 92a9741050a8f9b4dcfc753263befa04bc029abb Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 18 Aug 2026 14:04:50 +0800 Subject: [PATCH 034/248] docs(llm): anchor unified request-image management design PR From 8f83853b601b29286e10c7668dc19b8230e30463 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 10:56:49 +0800 Subject: [PATCH 035/248] refactor(attachment): saveImage returns the canonical ref beside source facts AttachmentStore.saveImage now resolves SavedImageAttachment: the durable reference paired with the submitted raster's intrinsic facts, so a store may persist a canonical re-encoding while callers keep the source dimensions for coordinate mapping. saveImages keeps returning refs; every fake store and the cordis API catalog follow the new signature. --- docs/subsystems/attachment.i18n.yaml | 4 ++-- docs/subsystems/attachment.md | 8 ++++++-- docs/subsystems/attachment.zh.md | 8 ++++++-- packages/acp/acp/tests/dispose.spec.ts | 2 +- packages/acp/acp/tests/harness.ts | 9 ++++++--- packages/acp/acp/tests/turns.spec.ts | 6 +++--- .../attachment/attachment-local/src/index.ts | 4 ++-- .../attachment/attachment-local/src/store.ts | 18 ++++++++++++----- packages/attachment/attachment/src/index.ts | 13 +++++++++--- packages/attachment/attachment/src/types.ts | 20 +++++++++++++++++++ .../attachment/attachment/tests/index.spec.ts | 18 ++++++++++------- .../extensions/tool-cordis/src/api-catalog.ts | 14 ++++++++++--- packages/fs/tool-fs/src/read-image.ts | 2 +- packages/fs/tool-fs/tests/read-image.spec.ts | 13 +++++++----- .../command-goal/tests/command-goal.spec.ts | 9 ++++++--- .../apiproxy/tests/api-proxy-models.spec.ts | 15 ++++++++------ .../commands/tests/commands.spec.ts | 12 ++++++++--- .../llm-deepseek/tests/dynamic-config.spec.ts | 8 ++++++-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 3 ++- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 3 ++- .../mcp/mcp-client/tests/mcp-client.spec.ts | 10 +++++++--- .../plan/plan-mode/tests/plan-mode.spec.ts | 5 +++-- scripts/gen-cordis-catalog.ts | 2 ++ scripts/gen-tool-catalog.ts | 4 ++-- scripts/test-invariants.ts | 3 ++- 25 files changed, 150 insertions(+), 63 deletions(-) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 11f7369862..f904af9a27 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: 180b7e06f0461dd4136779917e0732921704803b -attachment.zh.md: 35aa24ec5957b41e12a543fd76ea20604894cd18 +attachment.md: 780d4744dc7ca8cada6209476cd208cf8ef95bc2 +attachment.zh.md: 843eca1c4deda9d3499207a2d0f163e401a50c9b diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 180b7e06f0..780d4744dc 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -120,10 +120,14 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 35aa24ec59..843eca1c4d 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -120,10 +120,14 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 4aa32f078c..e5a4a66a3b 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -30,7 +30,7 @@ describe('ACP connection ownership', () => { it('disposal drains asynchronous assistant image delivery before releasing sessions', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' }) + const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index ce6e93794f..7c0532e92d 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -13,7 +13,7 @@ import { type Stream, } from '@agentclientprotocol/sdk' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -99,7 +99,7 @@ class MemoryAttachmentStore extends AttachmentStore { if (input.data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') } - saveImage(input: SaveImageAttachment): Promise { + saveImage(input: SaveImageAttachment): Promise { this.saved.push(input) const digest = createHash('sha256').update(input.data).digest('hex') const ref: ImageAttachmentRef = { @@ -110,7 +110,10 @@ class MemoryAttachmentStore extends AttachmentStore { height: 1, } this.objects.set(ref.attachmentId, { ref, data: Uint8Array.from(input.data) }) - return Promise.resolve(ref) + return Promise.resolve({ + ref, + source: { mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }, + }) } async readImage(ref: ImageAttachmentRef): Promise { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 71e2a21e43..c9b229caf1 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -44,7 +44,7 @@ describe('ACP prompt lifecycle', () => { it('delivers a committed assistant image as verified ACP base64', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' }) + const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { @@ -68,7 +68,7 @@ describe('ACP prompt lifecycle', () => { it('preserves committed text/image/text order on the ACP wire', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' }) + const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' }) script.push([ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'block-end', index: 0, block: { type: 'text', text: 'before' } }, @@ -92,7 +92,7 @@ describe('ACP prompt lifecycle', () => { it('does not settle a prompt before ordered output delivery drains', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' }) + const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index a4047da1f1..b529270c31 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -4,7 +4,7 @@ import { join, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { readImageFile, saveImageFile, validateImageFile } from './store.ts' @@ -75,7 +75,7 @@ export class LocalAttachmentStore extends AttachmentStore { await validateImageFile(input, this.imageLimits) } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { return saveImageFile(this.root, input, this.imageLimits) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 723df98720..f98dbf0765 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -12,6 +12,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, + SavedImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { detectImage, probeImage } from './image.ts' @@ -131,9 +132,13 @@ async function ensureDurableHome(path: string): Promise { * @param root - absolute `DSH_HOME/attachments/v1` root. * @param input - encoded bytes and declared metadata. * @param limits - resolved storage policy. - * @returns durable content-addressed reference. + * @returns durable content-addressed reference beside the submitted source facts. */ -export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { +export async function saveImageFile( + root: string, + input: SaveImageAttachment, + limits: ImageAttachmentLimits, +): Promise { if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') const metadata = await inspectMetadata(input.data, input.mediaType, limits) const sha256 = digest(input.data) @@ -187,9 +192,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li } const name = displayName(input.name) return { - attachmentId: AttachmentId(`sha256:${sha256}`), - ...metadata, - ...(name !== undefined ? { name } : {}), + ref: { + attachmentId: AttachmentId(`sha256:${sha256}`), + ...metadata, + ...(name !== undefined ? { name } : {}), + }, + source: metadata, } } diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 1480751c15..8b3f81a98f 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -6,6 +6,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, + SavedImageAttachment, StoredImageAttachment, } from './types.ts' @@ -20,6 +21,8 @@ export type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment, + SavedImageAttachment, + SourceImageInfo, StoredImageAttachment, } from './types.ts' @@ -71,16 +74,20 @@ export abstract class AttachmentStore extends Service { for (const input of inputs) await this.validateImage(input) const refs: ImageAttachmentRef[] = [] - for (const input of inputs) refs.push(await this.saveImage(input)) + for (const input of inputs) refs.push((await this.saveImage(input)).ref) return refs } /** * Validate and durably commit one image before its owning session event is appended. + * Implementations may store a canonical re-encoding of the submitted raster; + * the returned reference always describes the stored bytes, while `source` + * preserves the submitted raster's intrinsic facts for callers that report + * or map coordinates against the original. * @param input - encoded bytes, declared media type, and optional display name. - * @returns a durable content-addressed reference. + * @returns the durable content-addressed reference beside the submitted source facts. */ - abstract saveImage(input: SaveImageAttachment): Promise + abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 7c29231172..93cbf6a3db 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -58,3 +58,23 @@ export interface StoredImageAttachment { ref: ImageAttachmentRef data: Uint8Array } + +/** Intrinsic facts of the submitted source raster, before any canonical re-encoding. */ +export interface SourceImageInfo { + /** Media type verified from the submitted bytes. */ + mediaType: ImageMediaType + /** Exact submitted encoded byte length. */ + bytes: number + /** Intrinsic width of the submitted raster in pixels. */ + width: number + /** Intrinsic height of the submitted raster in pixels. */ + height: number +} + +/** Commit result pairing the durable reference with the submitted source raster it was derived from. */ +export interface SavedImageAttachment { + /** Durable reference describing the stored bytes. */ + ref: ImageAttachmentRef + /** Submitted source raster facts; equals the `ref` fields when the store kept the submitted bytes. */ + source: SourceImageInfo +} diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 622b797ce2..b3460a77ab 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -7,6 +7,7 @@ import AttachmentStore, { type ImageAttachmentRef, type ImageMediaType, type SaveImageAttachment, + type SavedImageAttachment, type StoredImageAttachment, } from '../src/index.ts' @@ -31,17 +32,20 @@ class RecordingStore extends AttachmentStore { if (value === this.rejectValidationAt) throw new Error(`invalid:${value}`) } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { const value = input.data[0] ?? 0 this.calls.push(`save:${value}`) if (value === this.rejectSaveAt) throw new Error(`write:${value}`) return { - attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`), - mediaType: input.mediaType, - bytes: input.data.byteLength, - width: 1, - height: 1, - ...input.name === undefined ? {} : { name: input.name }, + ref: { + attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`), + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }, + source: { mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1 }, } } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c821662e85..60f8ac66f3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -443,10 +443,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'durable references in the exact input order.', }, { - signature: 'abstract saveImage(input: SaveImageAttachment): Promise', - description: 'Validate and durably commit one image before its owning session event is appended.', + signature: 'abstract saveImage(input: SaveImageAttachment): Promise', + description: 'Validate and durably commit one image before its owning session event is appended. Implementations may store a canonical re-encoding of the submitted raster; the returned reference always describes the stored bytes, while `source` preserves the submitted raster\'s intrinsic facts for callers that report or map coordinates against the original.', parameters: [{ name: 'input', description: 'encoded bytes, declared media type, and optional display name.' }], - returns: 'a durable content-addressed reference.', + returns: 'the durable content-addressed reference beside the submitted source facts.', }, { signature: 'abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise', @@ -4000,6 +4000,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, + { + name: 'SavedImageAttachment', + declaration: 'export interface SavedImageAttachment {\n ref: ImageAttachmentRef;\n source: SourceImageInfo;\n}', + }, { name: 'SaveImageAttachment', declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', @@ -4404,6 +4408,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillViewOptions', declaration: 'export interface SkillViewOptions extends SkillLookupOptions {\n readonly scope?: ScopeKey | undefined;\n}', }, + { + name: 'SourceImageInfo', + declaration: 'export interface SourceImageInfo {\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n}', + }, { name: 'SpawnTeammateRequest', declaration: 'export interface SpawnTeammateRequest {\n readonly name: string;\n readonly description: string;\n readonly prompt: ContentBlock[];\n readonly context: \'fresh\' | \'fork\';\n readonly provider: string;\n readonly signal: AbortSignal;\n}', diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 92684971a7..074f816991 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -187,7 +187,7 @@ export function applyReadImageTool(ctx: Context): void { // committed object by the time the tool/result event is appended. let ref: ImageAttachmentRef try { - ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) + ref = (await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) })).ref } catch (error: unknown) { if (!(error instanceof AttachmentError)) throw error // Dimension refusals stay recoverable tool errors: an oversized image diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 7c52db5a8f..ca79c86315 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -21,7 +21,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import { AttachmentError, AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { applyReadImageTool, @@ -344,7 +344,7 @@ describe('argument and service preconditions', () => { throw new Error('unreachable: admission refuses before validation') } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { throw new Error('unreachable: admission refuses before save') } @@ -421,7 +421,7 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(_input: SaveImageAttachment): Promise { + async saveImage(_input: SaveImageAttachment): Promise { throw FailingStore.failure } @@ -475,8 +475,11 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(input: SaveImageAttachment): Promise { - return { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 } + async saveImage(input: SaveImageAttachment): Promise { + return { + ref: { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 }, + source: { mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 }, + } } readImage(_ref: ImageAttachmentRef): Promise { diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 2127844646..aa163784df 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -243,8 +243,11 @@ describe('/goal image attachments', () => { const saveImage = (input: { mediaType: string; name?: string }) => { saved += 1 return Promise.resolve({ - attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, - ...input.name === undefined ? {} : { name: input.name }, + ref: { + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }, + source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, }) } test.ctx.provide('attachments', { @@ -256,7 +259,7 @@ describe('/goal image attachments', () => { saveImage, async saveImages(inputs: readonly { mediaType: string; name?: string }[]) { const refs = [] - for (const input of inputs) refs.push(await saveImage(input)) + for (const input of inputs) refs.push((await saveImage(input)).ref) return refs }, }) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index d353ad0e62..55cb15ca9f 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -134,12 +134,15 @@ describe('Web session model selection', () => { const { ctx, agent, sessionId } = await harness() const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve()) const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({ - attachmentId: `att-${String(input.data[0])}`, - mediaType: input.mediaType, - bytes: input.data.byteLength, - width: 1, - height: 1, - ...input.name === undefined ? {} : { name: input.name }, + ref: { + attachmentId: `att-${String(input.data[0])}`, + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }, + source: { mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1 }, })) const attachments = { imageLimits: { diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 755bb0ab2f..5c80748227 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -479,8 +479,11 @@ describe('image attachments', () => { saveImage: vi.fn((input: { mediaType: string; name?: string }) => { saved += 1 return Promise.resolve({ - attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, - ...input.name === undefined ? {} : { name: input.name }, + ref: { + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, + }, + source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, }) }), // The real base-class batch method over this double's limits and members. @@ -585,7 +588,10 @@ describe('image attachments', () => { const store = storeOf() store.saveImage.mockImplementationOnce((input: { mediaType: string }) => { controller.abort('operator cancelled during admission') - return Promise.resolve({ attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }) + return Promise.resolve({ + ref: { attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + }) }) ctx.provide('attachments', store) const { agent } = await mintAgentScope(ctx, 'a') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index ac2043170a..c048f68920 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -8,6 +8,7 @@ import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -43,8 +44,11 @@ class StaticAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve(IMAGE_REF) + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve({ + ref: IMAGE_REF, + source: { mediaType: IMAGE_REF.mediaType, bytes: IMAGE_REF.bytes, width: IMAGE_REF.width, height: IMAGE_REF.height }, + }) } readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a36a180f7d..c7336cb8d7 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -4,6 +4,7 @@ import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -227,7 +228,7 @@ describe('PiAiAdapter provider routing', () => { return Promise.reject(new Error('not used')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 1fe529336f..b0b1dbba9a 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -5,6 +5,7 @@ import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -78,7 +79,7 @@ async function harness(image?: StoredImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 164c230c56..9f4854e2d8 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -3,7 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { Context } from '@deepseek-ai/cordis' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { CallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -86,15 +86,19 @@ class RecordingAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(input: SaveImageAttachment): Promise { + saveImage(input: SaveImageAttachment): Promise { this.saved.push(input) const marker = input.data[0] ?? 0 - return Promise.resolve({ + const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${marker.toString(16).padStart(64, '0')}`), mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1, + } + return Promise.resolve({ + ref, + source: { mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }, }) } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 8285147953..d1b4be3058 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -653,7 +653,8 @@ describe('/plan', () => { const saveImage = (input: { mediaType: string }) => { saved += 1 return Promise.resolve({ - attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ref: { attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, }) } ctx.provide('attachments', { @@ -665,7 +666,7 @@ describe('/plan', () => { saveImage, async saveImages(inputs: readonly { mediaType: string }[]) { const refs = [] - for (const input of inputs) refs.push(await saveImage(input)) + for (const input of inputs) refs.push((await saveImage(input)).ref) return refs }, }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 136d9301b3..255ff45001 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -294,6 +294,8 @@ export const LINK_MAP: Readonly> = { EncodedImageAttachment: 'attachment.md', ImageAttachmentRef: 'attachment.md', SaveImageAttachment: 'attachment.md', + SavedImageAttachment: 'attachment.md', + SourceImageInfo: 'attachment.md', StoredImageAttachment: 'attachment.md', ShellExecRequest: 'shell.md', ShellExecSpec: 'shell.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8475fd585e..87eee0a7e4 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -25,7 +25,7 @@ import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import PlanModeController from '@deepseek-ai/dsh-plan-mode' import WebRuntime from '@deepseek-ai/dsh-web' @@ -83,7 +83,7 @@ class CatalogAttachmentStore extends AttachmentStore { return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest')) } - override saveImage(_input: SaveImageAttachment): Promise { + override saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest')) } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a3b96a90a3..4f57edc2f8 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -12,6 +12,7 @@ import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -125,7 +126,7 @@ class TestAttachmentStore extends AttachmentStore { return Promise.reject(new Error('test invariant attachment store does not validate images')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From 83a526eea1342f3be36c54554b43f8b98ca6c87d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 10:57:39 +0800 Subject: [PATCH 036/248] feat(attachment-local): store a deterministic canonical image encoding Admission now validates a wide source envelope (32MiB, 100MP, 16384px per side) and persists a canonical encoding instead of refusing large sources: EXIF orientation baked in, metadata stripped, long edge downscaled to the configured canonical target (default 2048px), PNG palette for alpha/PNG/GIF sources and a fixed JPEG quality ladder (85/75/60/45) until the canonical byte target holds (default 1MiB). In-budget PNG/JPEG/WebP passes through byte-identically so equal sources keep deduplicating to the same content address; GIF always re-encodes to the PNG of its first frame, pinning the first-frame meaning providers apply. Encoder parameters are fixed by design; only the canonical budget is deployment configuration. --- .../attachment-local/src/canonical.ts | 103 +++++++++++++ .../attachment/attachment-local/src/index.ts | 45 ++++-- .../attachment/attachment-local/src/store.ts | 21 ++- .../attachment-local/tests/canonical.spec.ts | 139 ++++++++++++++++++ .../attachment-local/tests/index.spec.ts | 10 +- .../attachment-local/tests/store.spec.ts | 64 +++++--- 6 files changed, 340 insertions(+), 42 deletions(-) create mode 100644 packages/attachment/attachment-local/src/canonical.ts create mode 100644 packages/attachment/attachment-local/tests/canonical.spec.ts diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/canonical.ts new file mode 100644 index 0000000000..ada2164566 --- /dev/null +++ b/packages/attachment/attachment-local/src/canonical.ts @@ -0,0 +1,103 @@ +/** + * Deterministic canonical image encoding. Admission stores this encoding, so + * the same source bytes always publish the same content address on one + * runtime: encoder parameters are fixed here, never configurable, because a + * parameter change would silently split the content-addressed space. The + * deployment chooses only the canonical budget (long edge and byte target). + */ + +import sharp, { type Sharp } from 'sharp' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { DetectedImage } from './image.ts' + +/** Deployment-resolved canonical encoding budget. */ +export interface CanonicalImagePolicy { + /** Long-edge target in pixels; a larger source is downscaled proportionally. */ + maxDimension: number + /** Encoded-byte target; a larger encoding falls down the fixed quality ladder. */ + maxBytes: number +} + +/** Canonical bytes beside the facts a durable reference records about them. */ +export interface CanonicalImage { + data: Uint8Array + mediaType: ImageMediaType + width: number + height: number +} + +/** JPEG quality ladder tried in order once the preferred encoding exceeds the byte target. */ +const JPEG_QUALITIES = [85, 75, 60, 45] as const + +/** Encode one prepared pipeline and report the exact output facts. */ +async function encode(pipeline: Sharp, mediaType: 'image/png' | 'image/jpeg'): Promise { + const { data, info } = await pipeline.toBuffer({ resolveWithObject: true }) + return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } +} + +/** + * Whether stored bytes may be the submitted bytes unchanged. Byte-identical + * passthrough is preferred whenever the source already fits the budget: it + * keeps re-submissions of the same original deduplicating to the same object + * and never re-encodes what no policy requires changing. GIF is excluded — + * only its first frame is model-visible, so admission pins that meaning into + * the stored object instead of letting each provider drop frames differently. + * @param detected - verified source format and dimensions. + * @param bytes - submitted encoded byte length. + * @param policy - resolved canonical budget. + * @returns whether the submitted encoding already is canonical. + */ +export function isCanonical(detected: DetectedImage, bytes: number, policy: CanonicalImagePolicy): boolean { + return detected.mediaType !== 'image/gif' + && bytes <= policy.maxBytes + && Math.max(detected.width, detected.height) <= policy.maxDimension +} + +/** + * Produce the canonical encoding of one fully validated source raster. + * Passthrough returns the submitted array; every re-encode bakes EXIF + * orientation into pixels, strips metadata, downscales to the policy's long + * edge, and encodes with fixed parameters: PNG (palette) for sources that + * carry alpha or were PNG/GIF, JPEG for photographic sources, falling down + * one fixed JPEG quality ladder until the byte target holds. + * @param data - submitted encoded bytes, already fully decoded by admission. + * @param detected - verified source format and dimensions. + * @param policy - resolved canonical budget. + * @returns canonical bytes and their reference facts. + * @throws AttachmentError `IMAGE_TOO_LARGE` when the smallest ladder step still exceeds the byte target. + */ +export async function canonicalizeImage( + data: Uint8Array, + detected: DetectedImage, + policy: CanonicalImagePolicy, +): Promise { + if (isCanonical(detected, data.byteLength, policy)) { + return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } + } + try { + const source = sharp(data, { failOn: 'error', limitInputPixels: false }) + const { hasAlpha } = await source.metadata() + const prepared = source.rotate().resize({ + width: policy.maxDimension, + height: policy.maxDimension, + fit: 'inside', + withoutEnlargement: true, + }) + const preferPng = hasAlpha || detected.mediaType === 'image/png' || detected.mediaType === 'image/gif' + if (preferPng) { + const png = await encode(prepared.clone().png({ compressionLevel: 9, palette: true }), 'image/png') + if (png.data.byteLength <= policy.maxBytes) return png + } + for (const quality of JPEG_QUALITIES) { + const jpeg = await encode( + prepared.clone().flatten({ background: '#ffffff' }).jpeg({ quality }), + 'image/jpeg', + ) + if (jpeg.data.byteLength <= policy.maxBytes) return jpeg + } + } catch (error) { + throw new AttachmentError('Unable to canonicalize image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } + throw new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE') +} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index b529270c31..cbd702c2bf 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -6,41 +6,50 @@ import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import type { CanonicalImagePolicy } from './canonical.ts' import { readImageFile, saveImageFile, validateImageFile } from './store.ts' +export { canonicalizeImage, isCanonical } from './canonical.ts' +export type { CanonicalImage, CanonicalImagePolicy } from './canonical.ts' export { readImageFile, saveImageFile, validateImageFile } from './store.ts' -/** Default maximum encoded bytes for one image. */ -export const DEFAULT_MAX_IMAGE_BYTES = 3.5 * 1024 * 1024 +/** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ +export const DEFAULT_MAX_IMAGE_BYTES = 32 * 1024 * 1024 /** Default maximum images in one prompt. */ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024 -/** Default maximum intrinsic pixels for one image. */ -export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 +/** Default maximum intrinsic pixels for one submitted image. */ +export const DEFAULT_MAX_IMAGE_PIXELS = 100_000_000 +/** Default per-side pixel cap for one submitted image. */ +export const DEFAULT_MAX_IMAGE_DIMENSION = 16384 /** - * Default maximum intrinsic width and height for one image. Deployed model - * routes reject any request whose history carries an image with a side above - * 2000px once the request holds many images, and an admitted image rides - * every later request of its session, so admission refuses at the same line - * to keep the durable history streamable. + * Default long-edge target of the stored canonical encoding. A larger source + * is admitted and downscaled to this edge, so admission bounds what rides + * every later model request without refusing ordinary large sources. */ -export const DEFAULT_MAX_IMAGE_DIMENSION = 2000 +export const DEFAULT_CANONICAL_MAX_DIMENSION = 2048 +/** Default byte target of the stored canonical encoding. */ +export const DEFAULT_CANONICAL_MAX_BYTES = 1024 * 1024 /** Local attachment backend configuration. */ export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one image. */ + /** Maximum encoded bytes accepted for one submitted image. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. */ maxImagesPerMessage?: number /** Maximum aggregate encoded image bytes accepted in one submitted message. */ maxMessageImageBytes?: number - /** Maximum intrinsic width multiplied by height accepted for one image. */ + /** Maximum intrinsic width multiplied by height accepted for one submitted image. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ maxImageDimension?: number + /** Long-edge pixel target of the stored canonical encoding. */ + canonicalMaxDimension?: number + /** Encoded-byte target of the stored canonical encoding. */ + canonicalMaxBytes?: number } /** Persistent content-addressed local attachment store. */ @@ -52,11 +61,15 @@ export class LocalAttachmentStore extends AttachmentStore { maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), + canonicalMaxDimension: z.number().step(1).min(1).default(DEFAULT_CANONICAL_MAX_DIMENSION), + canonicalMaxBytes: z.number().step(1).min(1).default(DEFAULT_CANONICAL_MAX_BYTES), }) /** Absolute versioned storage root. */ readonly root: string readonly imageLimits: ImageAttachmentLimits + /** Resolved canonical encoding budget applied by every save. */ + readonly canonicalPolicy: Readonly constructor(ctx: Context, config: Config) { super(ctx) @@ -69,6 +82,10 @@ export class LocalAttachmentStore extends AttachmentStore { maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) + this.canonicalPolicy = Object.freeze({ + maxDimension: config.canonicalMaxDimension ?? DEFAULT_CANONICAL_MAX_DIMENSION, + maxBytes: config.canonicalMaxBytes ?? DEFAULT_CANONICAL_MAX_BYTES, + }) } async validateImage(input: SaveImageAttachment): Promise { @@ -76,7 +93,7 @@ export class LocalAttachmentStore extends AttachmentStore { } async saveImage(input: SaveImageAttachment): Promise { - return saveImageFile(this.root, input, this.imageLimits) + return saveImageFile(this.root, input, this.imageLimits, this.canonicalPolicy) } async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index f98dbf0765..9da83e30a0 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -15,6 +15,8 @@ import type { SavedImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' +import { canonicalizeImage } from './canonical.ts' +import type { CanonicalImagePolicy } from './canonical.ts' import { detectImage, probeImage } from './image.ts' const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ @@ -128,20 +130,26 @@ async function ensureDurableHome(path: string): Promise { } /** - * Save and verify immutable image bytes below a versioned attachment root. + * Save and verify one image below a versioned attachment root. Admission + * validates the submitted source, then stores its deterministic canonical + * encoding; the returned reference describes the stored canonical bytes while + * `source` preserves the submitted raster's facts. * @param root - absolute `DSH_HOME/attachments/v1` root. * @param input - encoded bytes and declared metadata. - * @param limits - resolved storage policy. + * @param limits - resolved source admission policy. + * @param policy - resolved canonical encoding budget. * @returns durable content-addressed reference beside the submitted source facts. */ export async function saveImageFile( root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits, + policy: CanonicalImagePolicy, ): Promise { if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') const metadata = await inspectMetadata(input.data, input.mediaType, limits) - const sha256 = digest(input.data) + const canonical = await canonicalizeImage(input.data, metadata, policy) + const sha256 = digest(canonical.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') // Establish DSH_HOME itself against the filesystem root once per process. @@ -155,7 +163,7 @@ export async function saveImageFile( let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(input.data) + await handle.writeFile(canonical.data) await handle.sync() await handle.close() handle = undefined @@ -194,7 +202,10 @@ export async function saveImageFile( return { ref: { attachmentId: AttachmentId(`sha256:${sha256}`), - ...metadata, + mediaType: canonical.mediaType, + bytes: canonical.data.byteLength, + width: canonical.width, + height: canonical.height, ...(name !== undefined ? { name } : {}), }, source: metadata, diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/canonical.spec.ts new file mode 100644 index 0000000000..0441fbc462 --- /dev/null +++ b/packages/attachment/attachment-local/tests/canonical.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import sharp from 'sharp' +import { canonicalizeImage, isCanonical } from '../src/canonical.ts' +import type { CanonicalImagePolicy } from '../src/canonical.ts' +import { detectImage } from '../src/image.ts' + +const POLICY: CanonicalImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } + +/** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */ +function noisePixels(width: number, height: number): Uint8Array { + const pixels = new Uint8Array(width * height * 3) + let state = 0x2545f491 + for (let index = 0; index < pixels.length; index += 1) { + state = (state * 1103515245 + 12345) & 0x7fffffff + pixels[index] = state & 0xff + } + return pixels +} + +async function noiseImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise { + const image = sharp(noisePixels(width, height), { raw: { width, height, channels: 3 } }) + return new Uint8Array(await image.toFormat(format).toBuffer()) +} + +async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif', alpha = false): Promise { + const image = sharp({ + create: { width, height, channels: alpha ? 4 : 3, background: { r: 12, g: 200, b: 64, alpha: alpha ? 0.5 : 1 } }, + }) + return new Uint8Array(await image.toFormat(format, format === 'webp' && alpha ? { lossless: true } : {}).toBuffer()) +} + +describe('isCanonical', () => { + it('accepts an in-budget PNG/JPEG/WebP and refuses GIF, oversized edges, and oversized bytes', () => { + expect(isCanonical({ mediaType: 'image/png', width: 2048, height: 4 }, 100, POLICY)).toBe(true) + expect(isCanonical({ mediaType: 'image/gif', width: 4, height: 4 }, 100, POLICY)).toBe(false) + expect(isCanonical({ mediaType: 'image/jpeg', width: 2049, height: 4 }, 100, POLICY)).toBe(false) + expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4 }, POLICY.maxBytes + 1, POLICY)).toBe(false) + }) +}) + +describe('canonicalizeImage', () => { + it('passes an already-canonical source through byte-identically', async () => { + const data = await flatImage(6, 4, 'webp') + const detected = await detectImage(data) + + const canonical = await canonicalizeImage(data, detected, POLICY) + + expect(canonical.data).toBe(data) + expect(canonical).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 }) + }) + + it('downscales an oversized PNG to the long-edge target and stays PNG', async () => { + const data = await flatImage(10, 6, 'png') + const detected = await detectImage(data) + + const canonical = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + + expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) + await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 5, height: 3 }) + const again = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + expect(again.data).toEqual(canonical.data) + }) + + it('re-encodes the canonical output of a resize into itself (idempotence)', async () => { + const data = await flatImage(10, 6, 'png') + const first = await canonicalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + + const second = await canonicalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + + expect(second.data).toBe(first.data) + }) + + it('always re-encodes GIF to the PNG of its first frame', async () => { + const data = await flatImage(6, 4, 'gif') + const detected = await detectImage(data) + + const canonical = await canonicalizeImage(data, detected, POLICY) + + expect(canonical.mediaType).toBe('image/png') + await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 6, height: 4 }) + }) + + it('keeps alpha sources on PNG when the budget holds', async () => { + const data = await flatImage(9, 5, 'webp', true) + const detected = await detectImage(data) + + const canonical = await canonicalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) + + expect(canonical).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) + }) + + it('re-encodes an oversized photographic JPEG as JPEG', async () => { + const data = await noiseImage(64, 32, 'jpeg') + const detected = await detectImage(data) + + const canonical = await canonicalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) + + expect(canonical).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) + }) + + it('falls from PNG to the JPEG ladder when palette PNG exceeds the byte target', async () => { + // A smooth gradient: palette quantization dithers it into a sizable PNG + // while JPEG at quality 85 stays far smaller, so the budget between the + // two forces exactly one ladder hop. + const side = 256 + const pixels = new Uint8Array(side * side * 3) + for (let y = 0; y < side; y += 1) { + for (let x = 0; x < side; x += 1) { + const index = (y * side + x) * 3 + pixels[index] = x & 0xff + pixels[index + 1] = y & 0xff + pixels[index + 2] = (x + y) >> 1 & 0xff + } + } + const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer()) + const detected = await detectImage(data) + const paletteSize = (await sharp(data).png({ compressionLevel: 9, palette: true }).toBuffer()).byteLength + const jpegSize = (await sharp(data).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer()).byteLength + expect(jpegSize).toBeLessThan(paletteSize) + const budget = { maxDimension: 2048, maxBytes: paletteSize - 1 } + + const canonical = await canonicalizeImage(data, detected, budget) + + expect(canonical.mediaType).toBe('image/jpeg') + expect(canonical.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) + }) + + it('refuses a source that no ladder step fits into the byte target', async () => { + const data = await noiseImage(64, 64, 'png') + + await expect(canonicalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 10 })) + .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + }) + + it('maps an encoder fault on undecodable bytes to a storage failure', async () => { + await expect(canonicalizeImage(Uint8Array.of(1, 2, 3), { mediaType: 'image/png', width: 5000, height: 5000 }, POLICY)) + .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 92bbe3c0aa..0e86957f82 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -5,6 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import LocalAttachmentStore, { + DEFAULT_CANONICAL_MAX_BYTES, + DEFAULT_CANONICAL_MAX_DIMENSION, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_IMAGE_DIMENSION, DEFAULT_MAX_IMAGE_PIXELS, @@ -15,7 +17,7 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) - expect(DEFAULT_MAX_IMAGE_BYTES).toBe(3.5 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(32 * 1024 * 1024) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, @@ -24,6 +26,10 @@ describe('local attachment service', () => { maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) + expect(service.canonicalPolicy).toEqual({ + maxDimension: DEFAULT_CANONICAL_MAX_DIMENSION, + maxBytes: DEFAULT_CANONICAL_MAX_BYTES, + }) }) it('saves and reads through the service boundary', async () => { @@ -34,7 +40,7 @@ describe('local attachment service', () => { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', )) - const ref = await service.saveImage({ data, mediaType: 'image/png' }) + const { ref } = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) } finally { await rm(dshHome, { recursive: true, force: true }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index a5b831e933..8fdd076f6e 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -7,6 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import type { CanonicalImagePolicy } from '../src/canonical.ts' import { readImageFile, saveImageFile } from '../src/store.ts' const fsControl = vi.hoisted(() => ({ @@ -38,6 +39,8 @@ const PNG = Uint8Array.from(Buffer.from( 'base64', )) +const POLICY: CanonicalImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } + const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, maxImagesPerMessage: 2, @@ -79,7 +82,7 @@ describe('local attachment store', () => { const bucket = join(objects, sha256.slice(0, 2)) fsControl.syncedDirectories.length = 0 - await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) // Each process first proves DSH_HOME durable all the way to the filesystem // root; existence alone cannot vouch for a concurrent creator's fsync. @@ -104,7 +107,7 @@ describe('local attachment store', () => { it('creates and persists a missing nested home directory against the filesystem root', async () => { const storageRoot = join(await root(), 'home', 'attachments', 'v1') - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) @@ -113,12 +116,12 @@ describe('local attachment store', () => { const storageRoot = await root() const first = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png', - }, LIMITS) - const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + }, LIMITS, POLICY) + const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const sha256 = createHash('sha256').update(PNG).digest('hex') const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) - expect(first).toEqual({ + expect(first.ref).toEqual({ attachmentId: `sha256:${sha256}`, mediaType: 'image/png', bytes: PNG.byteLength, @@ -126,25 +129,44 @@ describe('local attachment store', () => { height: 1, name: 'pixel.png', }) - expect(second.attachmentId).toBe(first.attachmentId) + expect(first.source).toEqual({ mediaType: 'image/png', bytes: PNG.byteLength, width: 1, height: 1 }) + expect(second.ref.attachmentId).toBe(first.ref.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) if (process.platform !== 'win32') { expect((await stat(object)).mode & 0o777).toBe(0o600) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) } - await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) + await expect(readImageFile(storageRoot, first.ref)).resolves.toEqual({ ref: first.ref, data: PNG }) + }) + + it('stores the canonical encoding of an oversized source and reads it back verified', async () => { + const storageRoot = await root() + const oversized = new Uint8Array(await sharp({ + create: { width: 4, height: 4, channels: 3, background: { r: 9, g: 9, b: 9 } }, + }).png().toBuffer()) + + const saved = await saveImageFile(storageRoot, { + data: oversized, mediaType: 'image/png', name: 'big.png', + }, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 }) + + expect(saved.source).toEqual({ mediaType: 'image/png', bytes: oversized.byteLength, width: 4, height: 4 }) + expect(saved.ref).toMatchObject({ mediaType: 'image/png', width: 2, height: 2, name: 'big.png' }) + expect(saved.ref.bytes).not.toBe(oversized.byteLength) + const read = await readImageFile(storageRoot, saved.ref) + expect(read.data.byteLength).toBe(saved.ref.bytes) + expect(String(saved.ref.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) }) it('keeps admitted history readable after deployment limits become stricter', async () => { const storageRoot = await root() - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) it('forwards read cancellation to the filesystem and preserves its reason', async () => { const storageRoot = await root() - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const controller = new AbortController() fsControl.readSignals.length = 0 @@ -160,35 +182,35 @@ describe('local attachment store', () => { const storageRoot = await root() await expect(saveImageFile(storageRoot, { data: new Uint8Array(0), mediaType: 'image/png', - }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) await expect(saveImageFile(storageRoot, { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', - }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/jpeg', - }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' }) await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', - }, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + }, { ...LIMITS, maxImageBytes: 1 }, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) const wide = new Uint8Array(await sharp({ create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, }).png().toBuffer()) await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', - }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', - }, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 })).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) + }, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 }, POLICY)).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) const unnamed = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '\u0000', - }, LIMITS) - expect(unnamed).not.toHaveProperty('name') + }, LIMITS, POLICY) + expect(unnamed.ref).not.toHaveProperty('name') }) it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { const storageRoot = await root() - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const sha256 = String(ref.attachmentId).slice('sha256:'.length) const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await chmod(object, 0o600) @@ -216,11 +238,11 @@ describe('local attachment store', () => { const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true }) await writeFile(target, Uint8Array.of(1, 2, 3)) - await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) await writeFile(target, PNG) - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) }) @@ -231,7 +253,7 @@ describe('local attachment store', () => { const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await mkdir(target, { recursive: true }) - await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) }) }) From 6e17c20804cd5c0c59d0f9658f81febbaceb4077 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 10:59:14 +0800 Subject: [PATCH 037/248] feat(tool-fs): read_image reports downscaled dimensions and coordinate scale When the attachment store's canonical encoding shrinks the file on disk, the read_image envelope names the original dimensions and the multiplier that maps coordinates measured on the attached image back onto the file, and the output schema carries sourceWidth/sourceHeight for programmatic callers. --- packages/fs/tool-fs/src/read-image.ts | 20 +++++++++-- packages/fs/tool-fs/tests/read-image.spec.ts | 35 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 074f816991..0cfaa93903 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -40,6 +40,10 @@ export interface ImageReadValue { width: number height: number name?: string + /** Intrinsic width of the file on disk; present only when storage downscaled it. */ + sourceWidth?: number + /** Intrinsic height of the file on disk; present only when storage downscaled it. */ + sourceHeight?: number } } @@ -93,15 +97,20 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme /** * Format an image read as the model-facing envelope beside its image block. + * A downscaled read names the on-disk dimensions and the multiplier that maps + * coordinates measured on the attached image back onto the original file. * @param displayPath - the backend-resolved path rendered in the envelope's `` element. * @param image - the canonical image metadata to summarize. * @returns the model-facing envelope; the image itself rides the adjacent image block. */ export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string { + const scaled = image.sourceWidth !== undefined && image.sourceHeight !== undefined + ? ` (downscaled from ${image.sourceWidth}x${image.sourceHeight} px; multiply coordinates by ${(image.sourceWidth / image.width).toFixed(2)} to locate features in the original file)` + : '' return `${displayPath} image -${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes +${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes${scaled} ` } @@ -150,6 +159,8 @@ export function applyReadImageTool(ctx: Context): void { width: { type: 'integer', required: true }, height: { type: 'integer', required: true }, name: { type: 'string' }, + sourceWidth: { type: 'integer' }, + sourceHeight: { type: 'integer' }, }, }, }, @@ -186,8 +197,11 @@ export function applyReadImageTool(ctx: Context): void { // Persist before returning: the image block must reference a durably // committed object by the time the tool/result event is appended. let ref: ImageAttachmentRef + let source: { width: number; height: number } try { - ref = (await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) })).ref + const saved = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) + ref = saved.ref + source = saved.source } catch (error: unknown) { if (!(error instanceof AttachmentError)) throw error // Dimension refusals stay recoverable tool errors: an oversized image @@ -213,6 +227,7 @@ export function applyReadImageTool(ctx: Context): void { ) } ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) + const downscaled = source.width !== ref.width || source.height !== ref.height const value: ImageReadValue = { path: target.displayPath, image: { @@ -222,6 +237,7 @@ export function applyReadImageTool(ctx: Context): void { width: ref.width, height: ref.height, ...ref.name === undefined ? {} : { name: ref.name }, + ...downscaled ? { sourceWidth: source.width, sourceHeight: source.height } : {}, }, } return value diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index ca79c86315..6cea2cf18f 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -494,6 +494,41 @@ describe('image admission failures', () => { const image = result.content[1] as { attachment: ImageAttachmentRef } expect(image.attachment.name).toBeUndefined() }) + + it('names the on-disk dimensions and coordinate multiplier when storage downscales', async () => { + /** Store whose canonical encoding halves the source on both sides. */ + class DownscalingStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + async saveImage(input: SaveImageAttachment): Promise { + return { + ref: { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: 7, width: 2, height: 1 }, + source: { mediaType: input.mediaType, bytes: input.data.length, width: 4, height: 2 }, + } + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + await ctx.plugin(DownscalingStore) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(false) + expect(text(result)).toContain('image/png image, 2x1 px, 7 bytes (downscaled from 4x2 px; multiply coordinates by 2.00 to locate features in the original file)') + }) }) describe('registration surface', () => { From c6fa512e1581913ca1c4b3c2ed2364d3210176a9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 11:22:00 +0800 Subject: [PATCH 038/248] fix(attachment-local): keep reference field order stable for logged fixtures The canonical ref serializes mediaType, width, height, bytes in the order the pre-canonicalization store used, so existing session-log fixtures and logged histories keep byte-identical reference JSON. --- packages/attachment/attachment-local/src/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 9da83e30a0..27152d89cc 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -203,9 +203,9 @@ export async function saveImageFile( ref: { attachmentId: AttachmentId(`sha256:${sha256}`), mediaType: canonical.mediaType, - bytes: canonical.data.byteLength, width: canonical.width, height: canonical.height, + bytes: canonical.data.byteLength, ...(name !== undefined ? { name } : {}), }, source: metadata, From fec8aa62dfccfbb6bf9ba607e77672b36c80fce5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 11:22:01 +0800 Subject: [PATCH 039/248] docs(attachment): document canonical admission; pin wide-image acceptance snapshot READMEs (both languages) describe the wide source envelope, the canonical encoding and its fixed encoder parameters, and read_image's downscale envelope; tool/config catalogs regenerate for the new schema and Config fields. The read-image-dimension scenario now pins the acceptance the old 2000px admission cap refused: the 2001x1 source is admitted and stored byte-identically, so the fixture stays platform-independent. --- docs/config-catalog.md | 12 ++++++++---- examples/acp-agent/tests/acp.snapshot.ts | 8 ++++---- .../tests/snapshots/read-image-dimension/input.json | 2 +- .../snapshots/read-image-dimension/session.jsonl | 10 +++++----- .../read-image-dimension/stdout.expected.jsonl | 2 +- .../attachment/attachment-local/README.i18n.yaml | 4 ++-- packages/attachment/attachment-local/README.md | 7 ++++--- packages/attachment/attachment-local/README.zh.md | 7 ++++--- packages/attachment/attachment/README.i18n.yaml | 4 ++-- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 ++-- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- 14 files changed, 37 insertions(+), 31 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fa0e4caa36..c3ae3421d5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -327,20 +327,24 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one image. */ + /** Maximum encoded bytes accepted for one submitted image. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. */ maxImagesPerMessage?: number /** Maximum aggregate encoded image bytes accepted in one submitted message. */ maxMessageImageBytes?: number - /** Maximum intrinsic width multiplied by height accepted for one image. */ + /** Maximum intrinsic width multiplied by height accepted for one submitted image. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ maxImageDimension?: number + /** Long-edge pixel target of the stored canonical encoding. */ + canonicalMaxDimension?: number + /** Encoded-byte target of the stored canonical encoding. */ + canonicalMaxBytes?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:31`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:36`](../packages/attachment/attachment-local/src/index.ts) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c2d7e44403..a6f12fdad5 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -250,10 +250,10 @@ const SCENARIOS: Scenario[] = [ toolSchemasSource: 'read-image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, - // Authored keyless replay of the oversized-image refusal: admission rejects - // the 2001x1 fixture at the default 2000px per-side limit, the model sees a - // recoverable tool error, and the turn still completes — the image never - // enters durable history. + // Authored keyless replay of wide-image admission: the 2001x1 fixture sits + // inside the wide source envelope and the canonical budget, so read_image + // succeeds and the attachment keeps the source bytes byte-identically — + // the same read the pre-canonicalization 2000px admission cap refused. { name: 'read-image-dimension', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/input.json b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json index 43e6299ef8..ff366b1109 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/input.json +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/input.json @@ -8,7 +8,7 @@ }, { "op": "prompt", - "text": "Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE." + "text": "Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE." } ] } diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl index db755998fb..9bd9a47fb2 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl @@ -1,9 +1,9 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory, then reply with exactly the single word WIDE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -14,13 +14,13 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a25d70ac-2bd6-4e44-9121-ed74975ee229"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"{{cwd}}/wide.png\nimage\n\nimage/png image, 2001x1 px, 133 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:0333f95051f5c038cab720d90112f1775e9ff1f8f7dddc86653e80ff241c5720","mediaType":"image/png","bytes":133,"width":2001,"height":1,"name":"wide.png"}}],"isError":false}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WIDE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"WIDE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl index 7dbc881712..80d27b8114 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TOOLARGE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WIDE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 1d7c63c469..11216d1aa1 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: e4f2d5748768a1dc2a6b79c3ed9e364c56a67248 -README.zh.md: 6b548fb993faef996f1508ba9f9efc31b20fea64 +README.md: e8f89906f7bedb20b80a04ab6d2aa4b80c6d746f +README.zh.md: 61e1dde94751436bb4d8ff4dde1b68b1b10fc005 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index e4f2d57487..e8f89906f7 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte, total-pixel, and per-side dimension limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. The per-side default (2000px) stays below the strictest dimension bound deployed model routes enforce on requests carrying many images: an admitted image rides every later request of its session, so admission is the last point where a provider-rejected image can be kept out of durable history. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission fully decodes the raster against a wide source envelope — byte, total-pixel, and per-side caps (defaults 32MiB, 100MP, 16384px) — and then persists a deterministic canonical encoding instead of the submitted bytes: EXIF orientation is baked into pixels, metadata is stripped, the long edge is downscaled to the configured canonical target (default 2048px), sources with alpha or PNG/GIF lineage encode as palette PNG and photographic sources as JPEG, stepping down a fixed quality ladder (85/75/60/45) until the configured canonical byte target holds (default 1MiB). A PNG/JPEG/WebP source already inside the canonical budget is stored byte-identically, so equal originals keep deduplicating to one content address; GIF always re-encodes to the PNG of its first frame, pinning at admission the first-frame meaning providers apply. Encoder parameters are deliberately fixed rather than configurable, because a parameter change would silently split the content-addressed space; the deployment chooses only the source envelope and the canonical budget. An admitted image rides every later request of its session, so canonicalizing at admission is what bounds durable history without refusing ordinary large sources. Reads re-check the digest and logged metadata, and a later policy reduction does not make already-admitted history unreadable. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. @@ -12,10 +12,11 @@ Indirectly, through durable replay of historical user images and structured mode #### KV Cache effect -None beyond the image block owned by the requesting adapter. +Canonicalization happens once at admission and is deterministic, so a stored image contributes identical request bytes on every later turn; nothing here re-encodes per request. ## Known Limitations and Deferred Work - Objects are retained indefinitely; reference-aware garbage collection is deferred. - The local backend assumes the host and provider adapter share this filesystem service. -- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned. +- Animated GIF sources keep only their first frame; animation is outside the version-one image contract. +- The canonical encoder is pinned by the installed sharp/libvips build; an encoder upgrade re-addresses future saves of the same source while already-stored objects stay valid. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 6b548fb993..61e1dde947 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节、总像素和单边尺寸限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。单边默认值(2000px)低于已部署模型路由对携带多张图片的请求所强制执行的最严格尺寸上限:一张已接纳的图片会随会话之后的每次请求发送,准入是把必然被上游拒绝的图片挡在持久历史之外的最后一道关口。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入会按宽松的源图上限(字节、总像素、单边,默认 32MiB、1 亿像素、16384px)完整解码光栅图片,然后持久保存确定性的规范编码而不是提交的原始字节:EXIF 方向落实到像素并剥离元数据,长边等比缩放到配置的规范目标(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定的质量阶梯(85/75/60/45)递降,直到满足配置的规范字节目标(默认 1MiB)。已在规范预算内的 PNG/JPEG/WebP 源图按字节原样存储,因此相同原图始终去重到同一个内容地址;GIF 一律重编码为其首帧的 PNG,在准入时就固化提供方实际采用的首帧语义。编码器参数刻意固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。一张已接纳的图片会随会话之后的每次请求发送,所以在准入时规范化才能在不拒绝普通大图的前提下约束持久历史。读取会重新校验摘要和已记录的元数据,后续收紧限制不会导致已经接纳的历史记录变得不可读。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 @@ -12,10 +12,11 @@ #### KV 缓存影响 -除发起请求的适配器所持有的图片块外,不产生其他影响。 +规范化只在准入时发生一次且是确定性的,因此一张已存储的图片在之后每一轮贡献完全相同的请求字节;这里没有任何按请求重编码的环节。 ## 已知限制与待完成工作 - 对象会无限期保留;基于引用的垃圾回收尚未实现。 - 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 -- 动态 GIF 的元数据根据逻辑屏幕进行校验;逐帧解码策略由提供方持有。 +- 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。 +- 规范编码器由安装的 sharp/libvips 构建钉定;编码器升级会让同一源图之后的保存得到新地址,已存储对象保持有效。 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index fd02d455a4..97ec722871 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.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 packages/attachment/attachment/README.md -README.md: 19232bd4bb86ed33e56fcdca93999967822422ab -README.zh.md: e5e7aab7c1af30b2b101bdcd218044cd1095ae0d +README.md: 89bc3ca3a288450c43fefb5dde38da7f65218f43 +README.zh.md: ca13c9e66234b280f7fa9d01fc80bd026604831f diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 19232bd4bb..89bc3ca3a2 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: an implementation may persist a canonical re-encoding of the submitted raster, so the returned `ref` always describes the stored bytes while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and dimensions for callers that report or map coordinates against the original. `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index e5e7aab7c1..ca13c9e662 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:实现可以持久保存所提交光栅的规范重编码,因此返回的 `ref` 始终描述实际存储的字节,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和尺寸,供需要对照原图汇报或换算坐标的调用方使用。`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 068531d79a..3d9c4606c4 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.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 packages/fs/tool-fs/README.md -README.md: ce7c0ea9070e30c1e6b538933ff5c4605b8d59cc -README.zh.md: 88d27289a7ef087aa8ad2791fc9b9e0d3e1fba2e +README.md: 22384ddb18f2b36e9b8a177ee62eed9424ddcd6a +README.zh.md: 74c41f4f25d19089c40a52cff4e3dfa654630b1a diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index ce7c0ea907..22384ddb18 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -38,7 +38,7 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }` (the source fields appear only when the attachment store's canonical encoding downscaled the file, and the envelope then names the coordinate multiplier back to the original), `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. ## The tool is the executor; policy is an event gate diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 88d27289a7..74c41f4f25 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -38,7 +38,7 @@ await ctx.plugin(ToolFs) // this package — re 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`(source 两个字段仅在附件存储的规范编码缩小了该文件时出现,此时信封会写明换算回原图的坐标倍率),`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 ## 工具就是执行器;策略是事件门禁 From 867dc446976a9277e41f5f0b9f60c50697e8d4f6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 11:23:32 +0800 Subject: [PATCH 040/248] docs(notes): record the canonical image admission decision --- ...-08-20-canonical-image-admission.i18n.yaml | 6 ++++ .../2026-08-20-canonical-image-admission.md | 28 +++++++++++++++++++ ...2026-08-20-canonical-image-admission.zh.md | 28 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml new file mode 100644 index 0000000000..28a4b212df --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.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 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md +2026-08-20-canonical-image-admission.md: bae3ce8b93b7fbfc4d3233cb3ed019f6ab09c0b4 +2026-08-20-canonical-image-admission.zh.md: a8c55383eb0c8b244dc1fefe1186004e2b869d3e diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md new file mode 100644 index 0000000000..bae3ce8b93 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md @@ -0,0 +1,28 @@ +# Agent Note: Canonical image admission + +Status: implemented + +English | [中文](2026-08-20-canonical-image-admission.zh.md) + +## Problem + +Admission used to refuse any image above 2000px per side or 3.5 MiB, because an admitted image rides every later request and deployed routes reject oversized images. Refusal pushed the problem onto the user (downscale by hand, re-attach), and the byte size of admitted images was uncontrolled below the cap, so long sessions accumulated large request payloads. The unified image-pipeline design (PR #2676) needs a canonical, deterministic stored form as the basis for content-addressed dedup, stable request bytes, and a later provider-files upload path. + +## Decision + +`AttachmentStore.saveImage` resolves `SavedImageAttachment`: the durable `ref` describing stored bytes beside `source` facts of the submitted raster. The local store validates a wide source envelope (32 MiB, 100 MP, 16384px per side) and persists a deterministic canonical encoding: EXIF orientation baked in, metadata stripped, long edge downscaled to `canonicalMaxDimension` (default 2048px), palette PNG for alpha/PNG/GIF lineage and JPEG for photographic sources, stepping a fixed quality ladder (85/75/60/45) until `canonicalMaxBytes` (default 1 MiB) holds. An in-budget PNG/JPEG/WebP source passes through byte-identically, so equal originals keep one content address; GIF always becomes the PNG of its first frame, pinning the first-frame meaning providers apply. Encoder parameters are fixed, not configurable — a parameter change would silently split the content-addressed space — so deployments choose only the source envelope and the canonical budget. The canonical ref keeps the pre-existing field order (`mediaType`, `width`, `height`, `bytes`) so logged references stay byte-identical. `read_image` reports the on-disk dimensions and the coordinate multiplier whenever storage downscaled the file. + +## Alternatives considered + +- **Keep refusing oversized sources.** Simple, but hostile at exactly the moment a user pastes a normal screenshot from a HiDPI display, and it leaves admitted byte sizes unbounded below the cap. +- **Canonicalize at request time.** Re-encoding per request breaks byte-stable prefixes (provider context caching) and violates the design's rule that durable content is written once; the request layer only projects. +- **Make encoder quality configurable.** Two deployments with different quality would address the same source at different ids, silently defeating dedup; fixed parameters keep the space whole and an encoder upgrade re-addresses only future saves. +- **Pin a resize transcript snapshot.** A fixture embedding re-encoded bytes depends on cross-platform encoder byte-stability (libvips resize and palette quantization across arm64/x86), which is unverified in CI; the assembled snapshot instead pins the acceptance passthrough (2001x1 admitted byte-identically), and re-encode branches are pinned by package tests. + +## Verification + +Package tests cover passthrough identity, resize determinism and idempotence, GIF-to-PNG, alpha-to-PNG, JPEG ladder descent, ladder exhaustion refusal, encoder-fault mapping, and the store round-trip of a downscaled save. The read-image suite pins the downscale envelope text. The `read-image-dimension` keyless snapshot now pins the acceptance the 2000px cap used to refuse, using passthrough bytes so the fixture is platform-independent. + +## Consequences + +Ordinary large sources are admitted and bounded (≤2048px, ≤1 MiB by default), shrinking per-request image payload roughly 3.5x at the old cap and making the planned request-level budgets rarely reachable. Stored bytes may differ from the submitted file; consumers that map coordinates use the saved `source` facts, as `read_image` does. A cross-platform byte-stability check for the re-encode path remains open before any fixture may embed re-encoded bytes. diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md new file mode 100644 index 0000000000..a8c55383eb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md @@ -0,0 +1,28 @@ +# Agent Note: 规范化图片准入 + +Status: implemented + +[English](2026-08-20-canonical-image-admission.md) | 中文 + +## 问题 + +准入过去拒绝任何单边超过 2000px 或超过 3.5 MiB 的图片,因为已接纳的图片会随之后每次请求发送,而已部署路由会拒绝过大的图片。拒绝把问题推给了用户(手动缩图再重新附上),而且上限以内的已接纳图片字节数不受控制,长会话会累积出很大的请求载荷。统一图片管线设计(PR #2676)需要一个规范且确定性的存储形态,作为内容寻址去重、请求字节稳定以及后续 provider files 上传路径的基础。 + +## 决定 + +`AttachmentStore.saveImage` 解析为 `SavedImageAttachment`:描述实际存储字节的持久 `ref`,加上所提交光栅的 `source` 事实。本地存储按宽松的源图上限(32 MiB、1 亿像素、单边 16384px)校验,然后持久保存确定性的规范编码:EXIF 方向落实到像素、剥离元数据、长边等比缩放到 `canonicalMaxDimension`(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定质量阶梯(85/75/60/45)递降直到满足 `canonicalMaxBytes`(默认 1 MiB)。已在预算内的 PNG/JPEG/WebP 源图按字节原样存储,相同原图保持同一个内容地址;GIF 一律转为首帧 PNG,在准入时固化提供方实际采用的首帧语义。编码器参数固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。规范 ref 保持原有字段顺序(`mediaType`、`width`、`height`、`bytes`),已记录的引用保持字节一致。存储缩小了文件时,`read_image` 会报告磁盘上的原始尺寸和坐标换算倍率。 + +## 考虑过的替代方案 + +- **继续拒绝超限源图。** 简单,但恰恰在用户从 HiDPI 屏幕粘贴一张普通截图的时刻表现得不友好,而且上限以内的已接纳字节数仍然无界。 +- **在请求时规范化。** 按请求重编码会破坏字节稳定前缀(provider 上下文缓存),也违反设计中「持久内容只写一次、请求层只做投影」的规则。 +- **让编码质量可配置。** 两个部署用不同质量会把同一源图寻址到不同 id,悄悄破坏去重;固定参数保持寻址空间完整,编码器升级只影响之后的保存。 +- **钉一个缩放的 transcript 快照。** 嵌入重编码字节的 fixture 依赖跨平台编码器字节稳定性(libvips 缩放与调色板量化在 arm64/x86 上的表现),CI 尚未验证;组装快照改为钉住接纳直通行为(2001x1 按字节原样接纳),重编码分支由包测试钉住。 + +## 验证 + +包测试覆盖直通恒等、缩放确定性与幂等、GIF 转 PNG、透明通道转 PNG、JPEG 阶梯递降、阶梯穷尽拒绝、编码器故障映射,以及缩小保存的存储往返。read-image 测试钉住缩放信封文本。`read-image-dimension` keyless 快照现在钉住 2000px 上限过去拒绝的接纳行为,使用直通字节因此 fixture 与平台无关。 + +## 后果 + +普通大图会被接纳并受约束(默认 ≤2048px、≤1 MiB),在旧上限处把单请求图片载荷缩小约 3.5 倍,使计划中的请求级预算正常情况下难以触达。存储字节可能与提交的文件不同;需要换算坐标的消费方使用保存的 `source` 事实,`read_image` 即如此。在任何 fixture 嵌入重编码字节之前,重编码路径的跨平台字节稳定性检查仍是待办。 From c90a944abda6f54d6fc25378d4b820ca0ec630d8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 11:30:05 +0800 Subject: [PATCH 041/248] docs: bring the zh config catalog along; pin read_image source fields in the code-mode prompt sidecar --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 12 ++++++++---- .../code-mode-read-image/system-prompt.expected.md | 2 ++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c5450730eb..a17400de81 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: fa0e4caa36a3754356876b13507530de82ceb37b -config-catalog.zh.md: 8125a8a80de3f82f6135292f0b834fde2867a840 +config-catalog.md: c3ae3421d52c2a6c4432b6c7784c1bae53625a24 +config-catalog.zh.md: 4e6b57a42e3269935cae8765cd0c7998c39115f4 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8125a8a80d..4e6b57a42e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -329,20 +329,24 @@ export interface Config { export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one image. */ + /** Maximum encoded bytes accepted for one submitted image. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. */ maxImagesPerMessage?: number /** Maximum aggregate encoded image bytes accepted in one submitted message. */ maxMessageImageBytes?: number - /** Maximum intrinsic width multiplied by height accepted for one image. */ + /** Maximum intrinsic width multiplied by height accepted for one submitted image. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ maxImageDimension?: number + /** Long-edge pixel target of the stored canonical encoding. */ + canonicalMaxDimension?: number + /** Encoded-byte target of the stored canonical encoding. */ + canonicalMaxBytes?: number } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:31`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:36`](../packages/attachment/attachment-local/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index 786aa8fe80..e3fdc4ace2 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -363,6 +363,8 @@ interface ToolOutputMap { width: number; height: number; name?: string; + sourceWidth?: number; + sourceHeight?: number; }; }; send_message: { From 118f244420de3bc43ae02a440e5f74e3395b858d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 12:08:37 +0800 Subject: [PATCH 042/248] fix(attachment-local): exclude metadata carriers and animation from passthrough; validate the canonical budget up front Review-round hardening of canonical admission: - passthrough now requires a single-frame source free of EXIF/XMP/IPTC metadata, so location/device metadata never enters durable storage and stored dimensions always describe the perceived pixels; animated WebP joins GIF on the always-re-encode path (first frame only) - SourceImageInfo records orientation-applied dimensions, keeping source and stored raster on shared axes for coordinate mapping - validateImage runs a canonical-encoding dry run, so a validated batch can no longer be refused mid-write by the byte target (no partial writes) - read_image names per-axis multipliers when rounding splits the two ratios and maps IMAGE_TOO_LARGE to actionable downscale guidance --- ...-08-20-canonical-image-admission.i18n.yaml | 4 +-- .../2026-08-20-canonical-image-admission.md | 2 +- ...2026-08-20-canonical-image-admission.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +-- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment-local/src/canonical.ts | 20 +++++++---- .../attachment/attachment-local/src/image.ts | 19 +++++++++- .../attachment/attachment-local/src/index.ts | 2 +- .../attachment/attachment-local/src/store.ts | 33 +++++++++++------ .../attachment-local/tests/canonical.spec.ts | 35 ++++++++++++++----- .../attachment-local/tests/image.spec.ts | 25 +++++++++++-- .../attachment-local/tests/index.spec.ts | 18 ++++++++++ .../attachment/attachment/README.i18n.yaml | 4 +-- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/types.ts | 4 +-- packages/fs/tool-fs/src/read-image.ts | 20 +++++++++-- packages/fs/tool-fs/tests/read-image.spec.ts | 12 +++++++ 19 files changed, 167 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml index 28a4b212df..d8a89613e9 100644 --- a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.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 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md -2026-08-20-canonical-image-admission.md: bae3ce8b93b7fbfc4d3233cb3ed019f6ab09c0b4 -2026-08-20-canonical-image-admission.zh.md: a8c55383eb0c8b244dc1fefe1186004e2b869d3e +2026-08-20-canonical-image-admission.md: a30031ef72942a61865525b9ed22f97afd71e18b +2026-08-20-canonical-image-admission.zh.md: d5402a6e7bfd2d8c7de6e2a7ce611c74ec2843d2 diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md index bae3ce8b93..a30031ef72 100644 --- a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md +++ b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md @@ -10,7 +10,7 @@ Admission used to refuse any image above 2000px per side or 3.5 MiB, because an ## Decision -`AttachmentStore.saveImage` resolves `SavedImageAttachment`: the durable `ref` describing stored bytes beside `source` facts of the submitted raster. The local store validates a wide source envelope (32 MiB, 100 MP, 16384px per side) and persists a deterministic canonical encoding: EXIF orientation baked in, metadata stripped, long edge downscaled to `canonicalMaxDimension` (default 2048px), palette PNG for alpha/PNG/GIF lineage and JPEG for photographic sources, stepping a fixed quality ladder (85/75/60/45) until `canonicalMaxBytes` (default 1 MiB) holds. An in-budget PNG/JPEG/WebP source passes through byte-identically, so equal originals keep one content address; GIF always becomes the PNG of its first frame, pinning the first-frame meaning providers apply. Encoder parameters are fixed, not configurable — a parameter change would silently split the content-addressed space — so deployments choose only the source envelope and the canonical budget. The canonical ref keeps the pre-existing field order (`mediaType`, `width`, `height`, `bytes`) so logged references stay byte-identical. `read_image` reports the on-disk dimensions and the coordinate multiplier whenever storage downscaled the file. +`AttachmentStore.saveImage` resolves `SavedImageAttachment`: the durable `ref` describing stored bytes beside `source` facts of the submitted raster. The local store validates a wide source envelope (32 MiB, 100 MP, 16384px per side) and persists a deterministic canonical encoding: EXIF orientation baked in, metadata stripped, long edge downscaled to `canonicalMaxDimension` (default 2048px), palette PNG for alpha/PNG/GIF lineage and JPEG for photographic sources, stepping a fixed quality ladder (85/75/60/45) until `canonicalMaxBytes` (default 1 MiB) holds. An in-budget PNG/JPEG/WebP source passes through byte-identically only when it is single-frame and free of EXIF/XMP/IPTC metadata and non-default orientation, so equal originals keep one content address while location and device metadata never survive admission; GIF and every animated or metadata-carrying source re-encodes, and GIF always becomes the PNG of its first frame, pinning the first-frame meaning providers apply. Encoder parameters are fixed, not configurable — a parameter change would silently split the content-addressed space — so deployments choose only the source envelope and the canonical budget. `SourceImageInfo` records orientation-applied dimensions so source and stored raster share axes, and `validateImage` includes a canonical-encoding dry run so a validated batch can never be refused mid-write by the byte target. The canonical ref keeps the pre-existing field order (`mediaType`, `width`, `height`, `bytes`) so logged references stay byte-identical. `read_image` reports the on-disk dimensions and the coordinate multiplier whenever storage downscaled the file, naming per-axis multipliers when integer rounding makes the two ratios differ. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md index a8c55383eb..d5402a6e7b 100644 --- a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决定 -`AttachmentStore.saveImage` 解析为 `SavedImageAttachment`:描述实际存储字节的持久 `ref`,加上所提交光栅的 `source` 事实。本地存储按宽松的源图上限(32 MiB、1 亿像素、单边 16384px)校验,然后持久保存确定性的规范编码:EXIF 方向落实到像素、剥离元数据、长边等比缩放到 `canonicalMaxDimension`(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定质量阶梯(85/75/60/45)递降直到满足 `canonicalMaxBytes`(默认 1 MiB)。已在预算内的 PNG/JPEG/WebP 源图按字节原样存储,相同原图保持同一个内容地址;GIF 一律转为首帧 PNG,在准入时固化提供方实际采用的首帧语义。编码器参数固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。规范 ref 保持原有字段顺序(`mediaType`、`width`、`height`、`bytes`),已记录的引用保持字节一致。存储缩小了文件时,`read_image` 会报告磁盘上的原始尺寸和坐标换算倍率。 +`AttachmentStore.saveImage` 解析为 `SavedImageAttachment`:描述实际存储字节的持久 `ref`,加上所提交光栅的 `source` 事实。本地存储按宽松的源图上限(32 MiB、1 亿像素、单边 16384px)校验,然后持久保存确定性的规范编码:EXIF 方向落实到像素、剥离元数据、长边等比缩放到 `canonicalMaxDimension`(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定质量阶梯(85/75/60/45)递降直到满足 `canonicalMaxBytes`(默认 1 MiB)。已在预算内的 PNG/JPEG/WebP 源图只有在单帧且不携带 EXIF/XMP/IPTC 元数据、方向为默认值时才按字节原样直通,相同原图保持同一个内容地址,位置与设备元数据绝不越过准入;GIF 以及任何动图或携带元数据的源图都会重编码,GIF 一律转为首帧 PNG,在准入时固化提供方实际采用的首帧语义。编码器参数固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。`SourceImageInfo` 记录应用方向之后的尺寸,使源图与存储光栅共享坐标轴;`validateImage` 包含规范编码干跑,通过校验的批次绝不会在写入中途被字节目标拒绝。规范 ref 保持原有字段顺序(`mediaType`、`width`、`height`、`bytes`),已记录的引用保持字节一致。存储缩小了文件时,`read_image` 会报告磁盘上的原始尺寸和坐标换算倍率,取整使两轴比例不一致时分轴给出。 ## 考虑过的替代方案 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 11216d1aa1..4393ddbe9d 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: e8f89906f7bedb20b80a04ab6d2aa4b80c6d746f -README.zh.md: 61e1dde94751436bb4d8ff4dde1b68b1b10fc005 +README.md: afa38ccc125f4fb36d35bb4b94b1aea278107551 +README.zh.md: 9de7ce65447a91741810bbcd41d397a70275247b diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index e8f89906f7..afa38ccc12 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission fully decodes the raster against a wide source envelope — byte, total-pixel, and per-side caps (defaults 32MiB, 100MP, 16384px) — and then persists a deterministic canonical encoding instead of the submitted bytes: EXIF orientation is baked into pixels, metadata is stripped, the long edge is downscaled to the configured canonical target (default 2048px), sources with alpha or PNG/GIF lineage encode as palette PNG and photographic sources as JPEG, stepping down a fixed quality ladder (85/75/60/45) until the configured canonical byte target holds (default 1MiB). A PNG/JPEG/WebP source already inside the canonical budget is stored byte-identically, so equal originals keep deduplicating to one content address; GIF always re-encodes to the PNG of its first frame, pinning at admission the first-frame meaning providers apply. Encoder parameters are deliberately fixed rather than configurable, because a parameter change would silently split the content-addressed space; the deployment chooses only the source envelope and the canonical budget. An admitted image rides every later request of its session, so canonicalizing at admission is what bounds durable history without refusing ordinary large sources. Reads re-check the digest and logged metadata, and a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission fully decodes the raster against a wide source envelope — byte, total-pixel, and per-side caps (defaults 32MiB, 100MP, 16384px) — and then persists a deterministic canonical encoding instead of the submitted bytes: EXIF orientation is baked into pixels, metadata is stripped, the long edge is downscaled to the configured canonical target (default 2048px), sources with alpha or PNG/GIF lineage encode as palette PNG and photographic sources as JPEG, stepping down a fixed quality ladder (85/75/60/45) until the configured canonical byte target holds (default 1MiB). A PNG/JPEG/WebP source already inside the canonical budget passes through byte-identically only when it is a single frame and carries no EXIF/XMP/IPTC metadata and no non-default orientation, so equal originals keep deduplicating to one content address while location and device metadata never survive admission; GIF and every animated or metadata-carrying source re-encodes, and GIF always becomes the PNG of its first frame, pinning at admission the first-frame meaning providers apply. Encoder parameters are deliberately fixed rather than configurable, because a parameter change would silently split the content-addressed space; the deployment chooses only the source envelope and the canonical budget. An admitted image rides every later request of its session, so canonicalizing at admission is what bounds durable history without refusing ordinary large sources. `validateImage` runs the same policy including a canonical-encoding dry run, so a validated batch can never be refused mid-write by the byte target. Reads re-check the digest and logged metadata, and a later policy reduction does not make already-admitted history unreadable. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 61e1dde947..9de7ce6544 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入会按宽松的源图上限(字节、总像素、单边,默认 32MiB、1 亿像素、16384px)完整解码光栅图片,然后持久保存确定性的规范编码而不是提交的原始字节:EXIF 方向落实到像素并剥离元数据,长边等比缩放到配置的规范目标(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定的质量阶梯(85/75/60/45)递降,直到满足配置的规范字节目标(默认 1MiB)。已在规范预算内的 PNG/JPEG/WebP 源图按字节原样存储,因此相同原图始终去重到同一个内容地址;GIF 一律重编码为其首帧的 PNG,在准入时就固化提供方实际采用的首帧语义。编码器参数刻意固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。一张已接纳的图片会随会话之后的每次请求发送,所以在准入时规范化才能在不拒绝普通大图的前提下约束持久历史。读取会重新校验摘要和已记录的元数据,后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入会按宽松的源图上限(字节、总像素、单边,默认 32MiB、1 亿像素、16384px)完整解码光栅图片,然后持久保存确定性的规范编码而不是提交的原始字节:EXIF 方向落实到像素并剥离元数据,长边等比缩放到配置的规范目标(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定的质量阶梯(85/75/60/45)递降,直到满足配置的规范字节目标(默认 1MiB)。已在规范预算内的 PNG/JPEG/WebP 源图只有在单帧且不携带 EXIF/XMP/IPTC 元数据、方向为默认值时才按字节原样直通,因此相同原图始终去重到同一个内容地址,而位置与设备元数据绝不会越过准入;GIF 以及任何动图或携带元数据的源图都会重编码,GIF 一律变为其首帧的 PNG,在准入时就固化提供方实际采用的首帧语义。编码器参数刻意固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。一张已接纳的图片会随会话之后的每次请求发送,所以在准入时规范化才能在不拒绝普通大图的前提下约束持久历史。`validateImage` 执行同一套策略并包含规范编码的干跑,因此通过校验的批次绝不会在写入中途被字节目标拒绝。读取会重新校验摘要和已记录的元数据,后续收紧限制不会导致已经接纳的历史记录变得不可读。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/canonical.ts index ada2164566..db4295c404 100644 --- a/packages/attachment/attachment-local/src/canonical.ts +++ b/packages/attachment/attachment-local/src/canonical.ts @@ -38,18 +38,26 @@ async function encode(pipeline: Sharp, mediaType: 'image/png' | 'image/jpeg'): P /** * Whether stored bytes may be the submitted bytes unchanged. Byte-identical - * passthrough is preferred whenever the source already fits the budget: it - * keeps re-submissions of the same original deduplicating to the same object - * and never re-encodes what no policy requires changing. GIF is excluded — - * only its first frame is model-visible, so admission pins that meaning into - * the stored object instead of letting each provider drop frames differently. - * @param detected - verified source format and dimensions. + * passthrough is preferred whenever the source already fits the budget and + * carries nothing the canonical form forbids: it keeps re-submissions of the + * same original deduplicating to the same object and never re-encodes what no + * policy requires changing. Excluded from passthrough — and therefore always + * re-encoded — are GIF and any animated container (only the first frame is + * model-visible, so admission pins that meaning instead of letting each + * provider drop frames differently) and any source carrying EXIF/XMP/IPTC + * metadata or a non-default orientation (stored objects ride every later + * request, so location and device metadata must not survive admission, and a + * stored orientation would let the recorded dimensions diverge from the + * pixels a model perceives). + * @param detected - verified source format, dimensions, and metadata facts. * @param bytes - submitted encoded byte length. * @param policy - resolved canonical budget. * @returns whether the submitted encoding already is canonical. */ export function isCanonical(detected: DetectedImage, bytes: number, policy: CanonicalImagePolicy): boolean { return detected.mediaType !== 'image/gif' + && !detected.animated + && !detected.carriesMetadata && bytes <= policy.maxBytes && Math.max(detected.width, detected.height) <= policy.maxDimension } diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index b067ea80ff..991e5dc051 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -7,8 +7,14 @@ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' /** Decoded metadata from a supported image. */ export interface DetectedImage { mediaType: ImageMediaType + /** Intrinsic width with EXIF orientation applied — the width a viewer perceives. */ width: number + /** Intrinsic height with EXIF orientation applied — the height a viewer perceives. */ height: number + /** Whether the container carries more than one frame. */ + animated: boolean + /** Whether the bytes carry EXIF/XMP/IPTC metadata or a non-default orientation. */ + carriesMetadata: boolean } const MEDIA_TYPES: Readonly> = { @@ -24,7 +30,18 @@ async function imageMetadata(image: Sharp): Promise { if (mediaType === undefined) { throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') } - return { mediaType, width: metadata.width, height: metadata.height } + // EXIF orientations 5-8 transpose the stored raster; report the perceived + // axes so limits, source facts, and coordinate advice all share them. + const transposed = metadata.orientation !== undefined && metadata.orientation >= 5 + return { + mediaType, + width: transposed ? metadata.height : metadata.width, + height: transposed ? metadata.width : metadata.height, + animated: (metadata.pages ?? 1) > 1, + // orientation is EXIF-derived for every whitelisted format, so exif + // presence already covers a non-default orientation. + carriesMetadata: metadata.exif !== undefined || metadata.xmp !== undefined || metadata.iptc !== undefined, + } } /** diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index cbd702c2bf..cbe535ae7d 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -89,7 +89,7 @@ export class LocalAttachmentStore extends AttachmentStore { } async validateImage(input: SaveImageAttachment): Promise { - await validateImageFile(input, this.imageLimits) + await validateImageFile(input, this.imageLimits, this.canonicalPolicy) } async saveImage(input: SaveImageAttachment): Promise { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 27152d89cc..9964c2a94f 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -13,11 +13,13 @@ import type { ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, + SourceImageInfo, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { canonicalizeImage } from './canonical.ts' import type { CanonicalImagePolicy } from './canonical.ts' import { detectImage, probeImage } from './image.ts' +import type { DetectedImage } from './image.ts' const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ const durableHomes = new Set() @@ -50,24 +52,35 @@ async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits, -): Promise> { +): Promise<{ detected: DetectedImage; source: SourceImageInfo }> { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension }) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') - return { ...detected, bytes: data.byteLength } + return { + detected, + source: { mediaType: detected.mediaType, bytes: data.byteLength, width: detected.width, height: detected.height }, + } } /** - * Run the full admission policy for one image without touching storage. + * Run the full admission policy for one image without touching storage, + * including a canonical-encoding dry run: a batch whose members all validate + * cannot later be refused mid-write by the canonical byte target. * @param input - encoded bytes and declared metadata. - * @param limits - resolved storage policy. - * @returns completion after the encoded raster has been fully decoded. + * @param limits - resolved source admission policy. + * @param policy - resolved canonical encoding budget. + * @returns completion after the raster has been fully decoded and its canonical encoding proven to fit. */ -export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { +export async function validateImageFile( + input: SaveImageAttachment, + limits: ImageAttachmentLimits, + policy: CanonicalImagePolicy, +): Promise { if (input.data.byteLength > limits.maxImageBytes) { throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') } - await inspectMetadata(input.data, input.mediaType, limits) + const { detected } = await inspectMetadata(input.data, input.mediaType, limits) + await canonicalizeImage(input.data, detected, policy) } /** @@ -147,8 +160,8 @@ export async function saveImageFile( policy: CanonicalImagePolicy, ): Promise { if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') - const metadata = await inspectMetadata(input.data, input.mediaType, limits) - const canonical = await canonicalizeImage(input.data, metadata, policy) + const { detected, source } = await inspectMetadata(input.data, input.mediaType, limits) + const canonical = await canonicalizeImage(input.data, detected, policy) const sha256 = digest(canonical.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') @@ -208,7 +221,7 @@ export async function saveImageFile( bytes: canonical.data.byteLength, ...(name !== undefined ? { name } : {}), }, - source: metadata, + source, } } diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/canonical.spec.ts index 0441fbc462..12d286a2bc 100644 --- a/packages/attachment/attachment-local/tests/canonical.spec.ts +++ b/packages/attachment/attachment-local/tests/canonical.spec.ts @@ -30,11 +30,14 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | } describe('isCanonical', () => { - it('accepts an in-budget PNG/JPEG/WebP and refuses GIF, oversized edges, and oversized bytes', () => { - expect(isCanonical({ mediaType: 'image/png', width: 2048, height: 4 }, 100, POLICY)).toBe(true) - expect(isCanonical({ mediaType: 'image/gif', width: 4, height: 4 }, 100, POLICY)).toBe(false) - expect(isCanonical({ mediaType: 'image/jpeg', width: 2049, height: 4 }, 100, POLICY)).toBe(false) - expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4 }, POLICY.maxBytes + 1, POLICY)).toBe(false) + it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => { + const clean = { animated: false, carriesMetadata: false } + expect(isCanonical({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) + expect(isCanonical({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false }, 100, POLICY)).toBe(false) + expect(isCanonical({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true }, 100, POLICY)).toBe(false) + expect(isCanonical({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) }) }) @@ -56,7 +59,7 @@ describe('canonicalizeImage', () => { const canonical = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 5, height: 3 }) + await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false }) const again = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(again.data).toEqual(canonical.data) }) @@ -77,7 +80,7 @@ describe('canonicalizeImage', () => { const canonical = await canonicalizeImage(data, detected, POLICY) expect(canonical.mediaType).toBe('image/png') - await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 6, height: 4 }) + await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false }) }) it('keeps alpha sources on PNG when the budget holds', async () => { @@ -132,8 +135,24 @@ describe('canonicalizeImage', () => { .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) }) + it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).jpeg().withMetadata({ orientation: 6 }).toBuffer()) + const detected = await detectImage(data) + // Orientation 6 rotates 90°: the perceived source is 2x4. + expect(detected).toMatchObject({ width: 2, height: 4, carriesMetadata: true }) + + const canonical = await canonicalizeImage(data, detected, POLICY) + + expect(canonical.data).not.toBe(data) + expect(canonical).toMatchObject({ width: 2, height: 4 }) + await expect(detectImage(canonical.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false }) + }) + it('maps an encoder fault on undecodable bytes to a storage failure', async () => { - await expect(canonicalizeImage(Uint8Array.of(1, 2, 3), { mediaType: 'image/png', width: 5000, height: 5000 }, POLICY)) + const detected = { mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false } as const + await expect(canonicalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) }) }) diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index 6b1cea6bfb..4398f986b7 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -18,7 +18,7 @@ describe('raster decoding', () => { ['gif', 'image/gif'], ] as const) { await expect(detectImage(await raster(format))) - .resolves.toEqual({ mediaType, width: 3, height: 2 }) + .resolves.toEqual({ mediaType, width: 3, height: 2, animated: false, carriesMetadata: false }) } }) @@ -31,7 +31,7 @@ describe('raster decoding', () => { await expect(detectImage(await raster('png'), { maxDimension: 2 })) .rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) await expect(detectImage(await raster('png'), { maxDimension: 3 })) - .resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2 }) + .resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2, animated: false, carriesMetadata: false }) }) it('rejects malformed bytes and truncated payloads with readable headers', async () => { @@ -47,6 +47,27 @@ describe('raster decoding', () => { await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) }) + it('reports animation from a multi-frame container and perceived axes from EXIF orientation', async () => { + const header = Buffer.from('47494638396101000100800000000000ffffff', 'hex') + const frame = Buffer.from('21f90401000000002c0000000001000100000202440100', 'hex') + const twoFrameGif = Uint8Array.from(Buffer.concat([header, frame, frame, Buffer.from('3b', 'hex')])) + await expect(detectImage(twoFrameGif)).resolves.toMatchObject({ mediaType: 'image/gif', animated: true }) + + const oriented = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).jpeg().withMetadata({ orientation: 6 }).toBuffer()) + await expect(detectImage(oriented)).resolves.toEqual({ + mediaType: 'image/jpeg', width: 2, height: 4, animated: false, carriesMetadata: true, + }) + + const flipped = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).jpeg().withMetadata({ orientation: 3 }).toBuffer()) + await expect(detectImage(flipped)).resolves.toEqual({ + mediaType: 'image/jpeg', width: 4, height: 2, animated: false, carriesMetadata: true, + }) + }) + it('probes malformed bytes and unsupported formats into the same stable error', async () => { await expect(probeImage(Uint8Array.of(1, 2, 3))) .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 0e86957f82..c4be530480 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -47,6 +47,24 @@ describe('local attachment service', () => { } }) + it('refuses a batch during validation when a member cannot meet the canonical byte target, before any write', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome, canonicalMaxBytes: 10 }) + const valid = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + await expect(service.saveImages([ + { data: valid, mediaType: 'image/png' }, + { data: valid, mediaType: 'image/png' }, + ])).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + expect(existsSync(service.root)).toBe(false) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) + it('validates without persisting: a rejected image leaves no storage root behind', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) try { diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 97ec722871..9c61d2fe81 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.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 packages/attachment/attachment/README.md -README.md: 89bc3ca3a288450c43fefb5dde38da7f65218f43 -README.zh.md: ca13c9e66234b280f7fa9d01fc80bd026604831f +README.md: 3b80444804a345bd019fe94f25954933aa549518 +README.zh.md: 37be4a4a9f54a7e7ddb5fdceb57711378c2f2cfc diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 89bc3ca3a2..3b80444804 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: an implementation may persist a canonical re-encoding of the submitted raster, so the returned `ref` always describes the stored bytes while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and dimensions for callers that report or map coordinates against the original. `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting, including any canonical-encoding dry run the implementation applies, so batch validation proves every member can also be committed. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: an implementation may persist a canonical re-encoding of the submitted raster, so the returned `ref` always describes the stored bytes while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and dimensions for callers that report or map coordinates against the original. `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index ca13c9e662..37be4a4a9f 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:实现可以持久保存所提交光栅的规范重编码,因此返回的 `ref` 始终描述实际存储的字节,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和尺寸,供需要对照原图汇报或换算坐标的调用方使用。`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整的准入策略但不执行持久化,包含实现所应用的规范编码干跑,因此批量校验能证明每个成员随后也能提交成功。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:实现可以持久保存所提交光栅的规范重编码,因此返回的 `ref` 始终描述实际存储的字节,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和尺寸,供需要对照原图汇报或换算坐标的调用方使用。`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 93cbf6a3db..22db4c6d23 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -65,9 +65,9 @@ export interface SourceImageInfo { mediaType: ImageMediaType /** Exact submitted encoded byte length. */ bytes: number - /** Intrinsic width of the submitted raster in pixels. */ + /** Perceived source width in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */ width: number - /** Intrinsic height of the submitted raster in pixels. */ + /** Perceived source height in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */ height: number } diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 0cfaa93903..cde24a2a35 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -104,9 +104,17 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme * @returns the model-facing envelope; the image itself rides the adjacent image block. */ export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string { - const scaled = image.sourceWidth !== undefined && image.sourceHeight !== undefined - ? ` (downscaled from ${image.sourceWidth}x${image.sourceHeight} px; multiply coordinates by ${(image.sourceWidth / image.width).toFixed(2)} to locate features in the original file)` - : '' + let scaled = '' + if (image.sourceWidth !== undefined && image.sourceHeight !== undefined) { + // Integer rounding can give the two axes slightly different ratios, so the + // advice names one multiplier only when both round to the same value. + const x = (image.sourceWidth / image.width).toFixed(2) + const y = (image.sourceHeight / image.height).toFixed(2) + const advice = x === y + ? `multiply coordinates by ${x}` + : `multiply x coordinates by ${x} and y coordinates by ${y}` + scaled = ` (downscaled from ${image.sourceWidth}x${image.sourceHeight} px; ${advice} to locate features in the original file)` + } return `${displayPath} image @@ -219,6 +227,12 @@ export function applyReadImageTool(ctx: Context): void { { cause: error }, ) } + if (error.code === 'IMAGE_TOO_LARGE') { + throw new Error( + `cannot read "${target.displayPath}": the image cannot be stored within the deployment's byte limits; downscale the image and read the smaller copy`, + { cause: error }, + ) + } if (error.code !== 'IMAGE_TYPE_MISMATCH') throw error const extension = extname(target.displayPath).toLowerCase() throw new Error( diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 6cea2cf18f..dcc6ab7d5e 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -438,6 +438,11 @@ describe('image admission failures', () => { expect(storageFault.isError).toBe(true) expect(text(storageFault)).toContain('Unable to persist image attachment.') + FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE') + const overBudget = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(overBudget.isError).toBe(true) + expect(text(overBudget)).toContain('cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy') + FailingStore.failure = new Error('unrelated infrastructure failure') const unrelated = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(unrelated.isError).toBe(true) @@ -529,6 +534,13 @@ describe('image admission failures', () => { expect(result.isError).toBe(false) expect(text(result)).toContain('image/png image, 2x1 px, 7 bytes (downscaled from 4x2 px; multiply coordinates by 2.00 to locate features in the original file)') }) + + it('names per-axis multipliers when integer rounding makes the ratios differ', () => { + const envelope = formatImageReadOutput('/img/photo.jpg', { + attachmentId: 'sha256:feed', mediaType: 'image/jpeg', bytes: 9, width: 2, height: 1, sourceWidth: 5, sourceHeight: 2, + }) + expect(envelope).toContain('downscaled from 5x2 px; multiply x coordinates by 2.50 and y coordinates by 2.00 to locate features in the original file') + }) }) describe('registration surface', () => { From c1bdac69398c624cce8f87f67cdbd74ba61667f3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 14:55:31 +0800 Subject: [PATCH 043/248] docs: propose attachment read quarantine --- ...6-07-05-reconstructable-requests.i18n.yaml | 4 +- .../2026-07-05-reconstructable-requests.md | 1 + .../2026-07-05-reconstructable-requests.zh.md | 1 + ...08-20-attachment-read-quarantine.i18n.yaml | 6 +++ .../2026-08-20-attachment-read-quarantine.md | 38 +++++++++++++++++++ ...026-08-20-attachment-read-quarantine.zh.md | 38 +++++++++++++++++++ 6 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml create mode 100644 .agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md create mode 100644 .agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index b2478911dc..47c4c2d198 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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 .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 63146fa2d392a45543daa32ce2b00158782fddb2 -2026-07-05-reconstructable-requests.zh.md: 94c1d323be0107eb8b6072a05d1e8832ebd1fffc +2026-07-05-reconstructable-requests.md: 3f49ba71a6b98a84b05530c900e902b0cf9f6449 +2026-07-05-reconstructable-requests.zh.md: 8eee44449140d656a669ac506057e4fa09c2f747 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 63146fa2d3..3f49ba71a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -51,5 +51,6 @@ Like MiniCode, the conversation advances append-only and resets only when model- - What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. - `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel. - Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. +- Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. - Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 94c1d323be..8eee444491 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -51,5 +51,6 @@ Status: implemented - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。 - 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。 +- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md)记录了不削弱字节精确重建的拟议恢复方案。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml new file mode 100644 index 0000000000..ce37d7b8d3 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.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 .agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md +2026-08-20-attachment-read-quarantine.md: 28e0f26cee2ec1e257fd4d43b4edc4300e2c6f23 +2026-08-20-attachment-read-quarantine.zh.md: bdc1d580a5159edcd288552e1bde9d80ea1eafd8 diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md new file mode 100644 index 0000000000..28e0f26cee --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md @@ -0,0 +1,38 @@ +# Agent Note: Quarantine unreadable historical attachments + +Status: proposed + +English | [中文](2026-08-20-attachment-read-quarantine.zh.md) + +## Problem + +An admitted `ImageAttachmentRef` remains in durable history and therefore participates in every later request until compaction replaces it. `AttachmentStore.readImage()` fails with `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, or `ATTACHMENT_READ_FAILED` when the referenced object disappears, fails integrity verification, or cannot be read. The unchanged history then makes every later model request fail on the same object, leaving the session unable to continue even though the remaining messages are usable. This is the unavailable-object case left fail-loud by [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md). + +## Proposal + +A session-backed image-request projection records unreadable references before provider dispatch. `ATTACHMENT_NOT_FOUND` and `ATTACHMENT_CORRUPT` immediately append `attachment/quarantine`; `ATTACHMENT_READ_FAILED` receives one cancellation-aware read retry and appends the same event with a retryable reason if the retry fails. Cancellation and unclassified failures do not quarantine data. + +The quarantine event identifies the attachment and failure class. Projection replaces each quarantined image with deterministic text containing its display name when present, attachment-id prefix, and failure class. Later requests derive the same replacement from the log and skip `readImage()` for that reference, while the original image block remains in append-only history. A request that discovers and records a quarantine reprojects before calling the provider, so the failed read does not become a terminal model-request attempt. + +Explicit recovery calls `readImage()` and appends `attachment/recovered` only after digest and metadata verification succeeds. Projection then restores the original image reference. Missing or corrupt bytes are never overwritten automatically, and clearing quarantine without verification is invalid. + +The shared request-projection consumer owns this policy. Attachment storage continues to report exact read failures, and provider adapters do not invent independent placeholders or recovery state. + +## Alternatives considered + +- **Keep failing every request.** This preserves strict error reporting but makes an otherwise usable durable session permanently unavailable after one storage fault. +- **Delete or rewrite the historical image block.** That loses evidence, violates append-only history, and prevents a repaired content-addressed object from restoring the original request. +- **Catch the error independently in each adapter.** An unlogged placeholder would make replay depend on which adapter and storage state happened to be present, while duplicated policies would drift. +- **Replace missing or corrupt bytes automatically.** The reference names verified immutable content; substituting different bytes under that identity would defeat integrity checking. + +## Acceptance criteria + +- A missing or corrupt historical image produces one durable quarantine transition and a stable placeholder; later model requests do not read that object or fail because of it. +- A general read failure is retried once without ignoring cancellation, then follows the retryable quarantine path. +- Restart and fork reconstruct the same quarantined request from the session log. +- Recovery restores image projection only after the original reference passes complete read verification. +- Package tests cover error classification, idempotent quarantine, cancellation, retry, recovery, and nested tool-result images; a keyless runnable snapshot pins the model-visible placeholder and durable events. + +## Risks + +Quarantine and recovery each change the provider prefix once. The implementation must identify the exact failing reference before recording state and must coordinate concurrent requests so duplicate failures produce one effective transition. Auxiliary calls without a live session cannot record recovery state; their failure policy remains explicit implementation scope rather than an adapter fallback. diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md new file mode 100644 index 0000000000..bdc1d580a5 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 隔离无法读取的历史附件 + +Status: proposed + +[English](2026-08-20-attachment-read-quarantine.md) | 中文 + +## 问题 + +已接纳的 `ImageAttachmentRef` 会留在持久历史中,因此在被压缩替换前都会参与之后的每次请求。引用对象丢失、完整性校验失败或无法读取时,`AttachmentStore.readImage()` 会返回 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。未变化的历史随后会让之后每次模型请求在同一对象上失败,使会话无法继续,即使其余消息仍可使用。这是[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)保留为明确失败的对象不可用情况。 + +## 提案 + +由会话支撑的图片请求投影在分派给提供方之前记录无法读取的引用。`ATTACHMENT_NOT_FOUND` 和 `ATTACHMENT_CORRUPT` 立即追加 `attachment/quarantine`;`ATTACHMENT_READ_FAILED` 先执行一次服从取消信号的读取重试,重试仍失败时追加同一事件并标记为可重试原因。取消和未分类失败不会隔离数据。 + +隔离事件标识附件和失败类别。投影把每张已隔离图片替换为确定性文本,包含可用时的显示名称、附件 ID 前缀和失败类别。之后的请求从日志派生相同替换结果,并跳过该引用的 `readImage()`,原始图片块仍留在仅追加历史中。请求发现并记录隔离后,会在调用提供方前重新投影,因此读取失败不会成为终止性的模型请求尝试。 + +显式恢复会调用 `readImage()`,且仅在内容摘要和元数据校验成功后追加 `attachment/recovered`。投影随后恢复原始图片引用。系统绝不会自动覆盖丢失或损坏的字节,也不允许未经验证就清除隔离。 + +共享请求投影消费方拥有这项策略。附件存储继续报告准确的读取失败,提供方适配器不会各自生成占位或恢复状态。 + +## 考虑过的替代方案 + +- **让每次请求继续失败。** 这保留了严格错误报告,但一次存储故障会让其他部分仍可使用的持久会话永久不可用。 +- **删除或重写历史图片块。** 这会丢失证据、违反仅追加历史,并使修复后的内容寻址对象无法恢复原始请求。 +- **由每个适配器分别捕获错误。** 未记录的占位会让回放取决于当时存在的适配器和存储状态,重复策略也会发生偏差。 +- **自动替换丢失或损坏的字节。** 引用标识经过验证的不可变内容;在该身份下替换成其他字节会破坏完整性校验。 + +## 接受标准 + +- 缺失或损坏的历史图片产生一次持久隔离转换和稳定占位;之后的模型请求不再读取该对象,也不会因它失败。 +- 一般读取失败会在服从取消信号的前提下重试一次,随后进入可重试隔离路径。 +- 重启和 fork 后会从会话日志重建相同的隔离请求。 +- 仅在原始引用通过完整读取校验后,恢复操作才恢复图片投影。 +- 包测试覆盖错误分类、幂等隔离、取消、重试、恢复和嵌套工具结果图片;一个无需密钥的可运行快照钉住模型可见占位和持久事件。 + +## 风险 + +隔离和恢复各会改变一次提供方前缀。实现必须在记录状态前识别准确的失败引用,并协调并发请求,使重复失败只产生一次有效转换。没有活跃会话的辅助调用无法记录恢复状态;它们的失败策略属于明确的实现范围,不能退回到适配器自行处理。 From d29855f97c406893a4167d74a53db521ab8b308b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 18:19:23 +0800 Subject: [PATCH 044/248] feat(images): unify master and Files request pipeline --- ...i-route-default-input-modalities.i18n.yaml | 4 +- ...12-pi-ai-route-default-input-modalities.md | 8 +- ...pi-ai-route-default-input-modalities.zh.md | 8 +- ...07-29-atomic-web-image-admission.i18n.yaml | 4 +- .../2026-07-29-atomic-web-image-admission.md | 12 +- ...026-07-29-atomic-web-image-admission.zh.md | 12 +- ...-image-dimension-admission-limit.i18n.yaml | 6 - ...6-08-17-image-dimension-admission-limit.md | 30 -- ...8-17-image-dimension-admission-limit.zh.md | 30 -- ...8-18-request-image-payload-bound.i18n.yaml | 6 - .../2026-08-18-request-image-payload-bound.md | 36 -- ...26-08-18-request-image-payload-bound.zh.md | 36 -- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 30 +- ...-image-input-and-durable-attachments.zh.md | 30 +- ...26-08-10-minimal-read-image-tool.i18n.yaml | 4 +- .../2026-08-10-minimal-read-image-tool.md | 19 +- .../2026-08-10-minimal-read-image-tool.zh.md | 19 +- ...mage-intake-and-limits-alignment.i18n.yaml | 4 +- ...2-web-image-intake-and-limits-alignment.md | 2 +- ...eb-image-intake-and-limits-alignment.zh.md | 2 +- ...2026-08-19-direct-deepseek-vision-input.md | 34 -- ...6-08-19-direct-deepseek-vision-input.zh.md | 34 -- ...-08-20-canonical-image-admission.i18n.yaml | 6 - .../2026-08-20-canonical-image-admission.md | 28 -- ...2026-08-20-canonical-image-admission.zh.md | 28 -- ...-unified-image-request-pipeline.i18n.yaml} | 6 +- ...26-08-20-unified-image-request-pipeline.md | 71 ++++ ...08-20-unified-image-request-pipeline.zh.md | 71 ++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 42 ++- docs/config-catalog.zh.md | 42 ++- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 105 +++++- docs/subsystems/attachment.zh.md | 105 +++++- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 12 + docs/subsystems/llm-streaming.zh.md | 12 + docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 55 ++- docs/tool-catalog.zh.md | 55 ++- examples/acp-agent/tests/acp.snapshot.ts | 125 ++++--- .../tests/fixtures/image-offload.cordis.yml | 3 +- .../system-prompt.expected.md | 40 ++ .../read-image/tool-schemas.expected.json | 46 +++ .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 10 +- .../attachment/attachment-local/README.zh.md | 10 +- .../attachment-local/src/canonical.ts | 230 ++++++++---- .../src/compression-limiter.ts | 43 +++ .../attachment-local/src/encoding.ts | 45 +++ .../attachment/attachment-local/src/image.ts | 26 +- .../attachment/attachment-local/src/index.ts | 172 +++++++-- .../attachment-local/src/request-image.ts | 353 ++++++++++++++++++ .../attachment/attachment-local/src/store.ts | 111 ++++-- .../attachment-local/tests/canonical.spec.ts | 208 +++++++++-- .../attachment-local/tests/encoding.spec.ts | 70 ++++ .../attachment-local/tests/image.spec.ts | 20 +- .../attachment-local/tests/index.spec.ts | 50 ++- .../tests/request-image.spec.ts | 209 +++++++++++ .../attachment-local/tests/store.spec.ts | 8 +- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 6 +- packages/attachment/attachment/README.zh.md | 6 +- packages/attachment/attachment/src/brand.ts | 12 + packages/attachment/attachment/src/error.ts | 1 + packages/attachment/attachment/src/index.ts | 84 ++++- packages/attachment/attachment/src/types.ts | 58 ++- .../attachment/attachment/tests/index.spec.ts | 35 ++ .../extensions/tool-cordis/src/api-catalog.ts | 58 ++- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 15 +- packages/fs/tool-fs/README.zh.md | 15 +- packages/fs/tool-fs/src/read-image.ts | 191 +++++++++- packages/fs/tool-fs/tests/read-image.spec.ts | 83 +++- packages/host/apiproxy/src/api-proxy.ts | 18 +- .../apiproxy/tests/api-proxy-models.spec.ts | 17 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 29 +- packages/llm/llm-deepseek/README.zh.md | 29 +- packages/llm/llm-deepseek/package.json | 6 + packages/llm/llm-deepseek/src/adapter.ts | 332 ++++++++++++---- packages/llm/llm-deepseek/src/file-id.ts | 27 ++ packages/llm/llm-deepseek/src/file-store.ts | 257 +++++++++++++ packages/llm/llm-deepseek/src/files-api.ts | 257 +++++++++++++ packages/llm/llm-deepseek/src/index.ts | 128 ++++++- packages/llm/llm-deepseek/src/serialize.ts | 136 ++++--- packages/llm/llm-deepseek/src/types.ts | 10 +- packages/llm/llm-deepseek/src/upload-index.ts | 225 +++++++++++ .../llm/llm-deepseek/tests/adapter.e2e.ts | 138 +++++-- .../llm/llm-deepseek/tests/adapter.spec.ts | 286 +++++++++++++- .../llm-deepseek/tests/dynamic-config.spec.ts | 31 +- .../llm/llm-deepseek/tests/file-store.spec.ts | 135 +++++++ .../llm/llm-deepseek/tests/files-api.spec.ts | 102 +++++ .../llm/llm-deepseek/tests/mock-server.ts | 130 +++++-- .../llm/llm-deepseek/tests/serialize.spec.ts | 206 +++++----- .../llm-deepseek/tests/upload-index.spec.ts | 73 ++++ packages/llm/llm-deepseek/tsconfig.json | 12 + packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 13 +- packages/llm/llm-pi-ai/README.zh.md | 13 +- packages/llm/llm-pi-ai/src/adapter.ts | 58 ++- packages/llm/llm-pi-ai/src/config.ts | 24 ++ packages/llm/llm-pi-ai/src/context.ts | 68 +++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 50 ++- packages/llm/llm-pi-ai/tests/context.spec.ts | 91 +++-- packages/llm/llm-pi-ai/tests/convert.spec.ts | 34 +- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 22 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 8 +- packages/llm/llm/README.zh.md | 8 +- packages/llm/llm/src/content.ts | 130 ++++++- packages/llm/llm/src/index.ts | 101 ++++- packages/llm/llm/tests/content.spec.ts | 32 +- packages/llm/llm/tests/service.spec.ts | 73 ++++ pnpm-lock.yaml | 9 + scripts/gen-cordis-catalog.ts | 3 + scripts/gen-tool-catalog.ts | 8 +- scripts/type-equiv.manifest.json | 20 + 122 files changed, 5566 insertions(+), 1186 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md delete mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md delete mode 100644 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md rename .agents/notes/implemented/feature/{2026-08-19-direct-deepseek-vision-input.i18n.yaml => 2026-08-20-unified-image-request-pipeline.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md create mode 100644 packages/attachment/attachment-local/src/compression-limiter.ts create mode 100644 packages/attachment/attachment-local/src/encoding.ts create mode 100644 packages/attachment/attachment-local/src/request-image.ts create mode 100644 packages/attachment/attachment-local/tests/encoding.spec.ts create mode 100644 packages/attachment/attachment-local/tests/request-image.spec.ts create mode 100644 packages/llm/llm-deepseek/src/file-id.ts create mode 100644 packages/llm/llm-deepseek/src/file-store.ts create mode 100644 packages/llm/llm-deepseek/src/files-api.ts create mode 100644 packages/llm/llm-deepseek/src/upload-index.ts create mode 100644 packages/llm/llm-deepseek/tests/file-store.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/files-api.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/upload-index.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml index 4ab07f7f07..9338e0cf6c 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.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 .agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md -2026-08-12-pi-ai-route-default-input-modalities.md: efd20b2cd73979208bb777fa42536bc5b918e29e -2026-08-12-pi-ai-route-default-input-modalities.zh.md: 069a7916c8d4ffe738ff910a851e0a9cd1f66d0a +2026-08-12-pi-ai-route-default-input-modalities.md: eb03d5330a1283d439e16262325965cf2e7e8087 +2026-08-12-pi-ai-route-default-input-modalities.zh.md: dfbbd2ae6db7a78e82955db66fe506d3506209fd diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md index efd20b2cd7..eb03d5330a 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md @@ -18,17 +18,17 @@ The assumption was justified in the source as the adapter's real capability rath **The route value is a fallback, not an override — the catalog outranks it.** This is the `default*` ordering rather than `compat`'s, and the two are not interchangeable: `compat` shadows the catalog because a route-level protocol repoint invalidates the catalog's reasoning-dispatch facts wholesale, while a modality is a per-model property the catalog states accurately for the models it ships. Making the route value win would mean `defaultInput: [text]` silently strips images from every catalog vision model on the route — a footgun with no matching benefit, since narrowing one such model is what that model's own `input` is for. -**Undeclared means `[text]`, and that is the absence of a declaration rather than a guess at the endpoint.** Nothing can interrogate a gateway for its modalities — no OpenAI-compatible listing endpoint reports them — so the only honest floor is the modality every supported protocol certainly carries. This is where the modality fallback parts company with the capacity ones: 262,144 tokens is merely plausible and wrong in both directions (a gateway serving 8k overflows, one serving 1M is wasted), while text is safe in one direction. The two wrong answers do not cost the same either. Under-claiming refuses the image before it is attached, naming the model, and the remedy is one documented line. Over-claiming admits an image the provider then rejects mid-turn, *after* prompt admission has committed the message durably, so the session keeps re-sending a request that cannot succeed and model selection refuses a switch to any text-only model. A cheap refusal at the earliest resolvable point beats an expensive one at the latest. +**Undeclared means `[text]`, and that is the absence of a declaration rather than a guess at the endpoint.** Nothing can interrogate a gateway for its modalities because no OpenAI-compatible listing endpoint reports them. The only safe floor is the modality every supported protocol certainly carries. Under-claiming refuses the image before it is attached, names the model, and has a documented configuration remedy. Over-claiming admits and persists an image before the provider can reject it. Later requests to that same incorrectly declared route will encounter the image again, although the user can select a text-only model because request assembly projects durable images to placeholders. **An entry's empty list means the same as an absent one; the route's is refused.** `[]` describes a model that accepts nothing and could serve no request, so it states no answer and resolution continues past it. That reading is not cosmetic: the config schema materializes `[]` for an absent array, so treating it as "accepts nothing" would silently strip images from every catalog vision model a `models` list happens to name. The route value has nothing below it to answer instead, so its empty list is refused where it is written. The route's `models` list already resolves absent-and-empty the same way for the same reason. **No configuration surface edits `input`.** It joins `compat`, `reasoningEfforts`, `thinkingBudgets`, and `headers` as a settings-document field, and the model-list editor stays a hand-written form over id, name, and the two capacities. This costs nothing durable because that card was already built to carry fields it does not edit: its row patch spreads the stored row before applying changes, and adoption keeps an existing row over a rediscovered candidate, so a hand-written `input` survives both. -The DeepSeek chat-completions adapter is untouched. Its `['text']` is a fact about its serializer, not a missing declaration, and it keeps refusing before the send. +The direct DeepSeek adapter owns a separate exact-model catalog. Its supported vision entry declares image input, while its text models and unlisted pass-through ids remain text-only. ## Alternatives considered -- **An optimistic `[text, image]` default** — makes the motivating case work with zero configuration, and the web form writes no modality at all, so a conservative default leaves the remedy in a file a web-only user has no reason to open. Rejected on the severity of being wrong: a refused attachment is a speed bump with a documented fix, while a provider rejection poisons the session, presents as an unexplained repeating failure, and is escapable only by switching models or starting over. Documenting the remedy on the model-configuration page closes the discoverability gap; nothing closes the poisoned session. +- **An optimistic `[text, image]` default** — makes the motivating case work with zero configuration, and the web form writes no modality at all, so a conservative default leaves the remedy in the settings document. Rejected because a false positive persists an image before the provider refuses it and causes repeated failure on that route. Text-only request projection provides recovery but does not make the declaration true. - **A route value that overrides the catalog** (`compat`'s ordering: entry → route → catalog) — lets a deployment that repoints a catalog route at its own gateway declare "no vision here" once. Rejected because the same sentence then silently disables every catalog vision model on a route where someone wrote it by analogy with the capacity fields, and the legitimate case is served by that model's own `input`. An override would also have to be named `input` at the route, since calling it `default*` beside two genuine fallbacks would misdescribe it. - **No route field at all, only the entry one** — closest to upstream, which has no route-level concept. Rejected on the bulk case the product's own flow produces: "fetch available models" adopts thirty ids with no modality, and an all-vision gateway would need `input` hand-written on each. - **A route-level `defaultInput` with no entry field** — cannot mix modalities on one route or correct a single catalog model, leaving "split the provider across two route keys" as the only workaround, at the cost of a second permanent provider id and a duplicate entry in every model selector. @@ -42,7 +42,7 @@ A vision model on a custom provider costs one line, `input: [text, image]`, writ The image-admission gate keeps its meaning everywhere, because every modality it reads is now either recorded by the installed catalog or written by a person. Nothing claims a capability on a deployment's behalf. -A model that declares images its endpoint does not serve is not caught locally — the claim is not verified — and the resulting failure is expensive. Prompt admission commits the user message durably (`agent/inbox/spliced`) before the request is built, so the rejected image stays in the session log: that model keeps re-sending it, and model selection refuses a switch to any text-only model. Recovery is to select a model that does serve images, fork before the image, or start a session. Making that failure non-destructive — rolling an unconsumed image message back out of the log when the send fails — is the change that would make an optimistic default reconsiderable, and is not attempted here. +A model that declares image input its endpoint does not serve is not caught locally because the claim is not verified. Prompt admission commits the user message durably before request construction, so the rejected image stays in the session log and later requests to that route can fail again. Recovery is to correct the declaration, select an image-capable route, or select a text-only route whose request projection replaces durable images with placeholders. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md index 069a7916c8..dfbbd2ae6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md @@ -18,17 +18,17 @@ Harness 把缺失的模态当作否定能力,并有三个准入点在构造任 **路由值是回退值而非覆盖值——catalog 的优先级更高。** 这采用的是 `default*` 的顺序而非 `compat` 的,两者不可互换:`compat` 之所以盖住 catalog,是因为路由级的协议改指会整体作废 catalog 关于推理分派的事实;而模态是按模型的属性,对 catalog 自己出货的那些模型,它记录得准确无误。让路由值获胜就意味着 `defaultInput: [text]` 会悄悄剥掉该路由上每一个 catalog 视觉模型的图片能力——一个没有对应收益的坑,因为收窄其中某个模型正是该模型自己的 `input` 要做的事。 -**未声明即 `[text]`,而这是「尚未声明」,不是对端点的猜测。** 没有任何环节能去询问网关的模态——没有任何 OpenAI 兼容的列表端点会报告它们——因此唯一诚实的底线是每个受支持协议都确定携带的那个模态。这也正是模态回退值与容量回退值分道扬镳之处:262,144 只是个说得过去的数字,且两个方向都会错(网关只给 8k 会溢出,给 1M 则被浪费),而 text 在一个方向上是安全的。两种猜错的代价同样并不对等。少声明会在图片被附加之前就拒绝并点名该模型,补救办法是一行有文档可依的配置。多声明会接纳一张图片、再由提供方在轮次中途拒绝——而此时 prompt 准入**早已**把消息持久化提交,于是会话会不断重发一个不可能成功的请求,且模型选择拒绝切换到任何纯文本模型。在最早可解析点付出一次廉价的拒绝,胜过在最晚点付出一次昂贵的。 +**未声明即 `[text]`,而这是「尚未声明」,不是对端点的猜测。** 没有任何环节能询问网关的模态,因为 OpenAI 兼容列表端点不会报告它们。安全的底线是每个受支持协议都确定携带的模态。少声明会在图片附加之前拒绝、点名模型,并给出有文档的配置补救方法。多声明会先接纳并持久化图片,再由提供方拒绝。之后对同一错误声明路由的请求还会再次遇到图片,但用户可以选择纯文本模型,因为请求组装会把持久图片投影为占位符。 **条目的空列表与缺省同义;路由的空列表则被拒绝。** `[]` 描述的是一个什么都不接受、无法服务任何请求的模型,因此不作答,解析继续往下走。这个读法不是修辞:配置 schema 会为缺省数组物化出 `[]`,把它当作“什么都不接受”,会悄悄剥掉 `models` 列表恰好点到的每一个 catalog 视觉模型的图片能力。而路由值下面没有可以代为作答的层级,因此它的空列表在写入处即被拒绝。路由的 `models` 列表出于同样的理由,早已用同一种方式解析缺省与空。 **没有任何配置界面编辑 `input`。** 它和 `compat`、`reasoningEfforts`、`thinkingBudgets`、`headers` 一样是 settings 文档字段,而模型列表编辑器仍是一张只覆盖 id、名称和两个容量的手写表单。这不会带来持久代价,因为那张卡片本来就是按“承载自己并不编辑的字段”建造的:它的行 patch 会先展开已存储的行再应用改动,而采纳候选时已有行优先于重新发现的候选,因此手写的 `input` 在两条路径上都能存活。 -DeepSeek chat-completions 适配器保持不动。它的 `['text']` 是关于其序列化器的事实,而不是一处缺失的声明,它继续在发送前拒绝。 +DeepSeek 直接适配器拥有独立的精确模型目录。支持视觉的条目声明图片输入,纯文本模型和未列出的透传 ID 保持纯文本。 ## 备选方案 -- **乐观的 `[text, image]` 默认值** —— 让触发本次变更的场景零配置即可工作;而且网页表单不会写入任何模态,因此保守默认值会把补救办法留在一个纯 Web 用户没有理由打开的文件里。被否决的理由是猜错时的严重程度:被拒绝的附件是一个有文档可依的减速带,而提供方拒绝会毒化整个会话、表现为一次无从解释的反复失败,且只能靠换模型或重开会话脱身。把补救办法写进配置模型页即可补上可发现性的缺口;而毒化的会话没有任何东西能补。 +- **乐观的 `[text, image]` 默认值** —— 让触发场景无需配置即可工作,而网页表单不会写入模态,因此保守默认值会把补救方法留在 settings 文档里。否决原因是错误的肯定声明会在提供方拒绝之前持久化图片,并让该路由重复失败。纯文本请求投影提供了恢复方法,但不能让错误声明变成事实。 - **让路由值盖住 catalog**(`compat` 的顺序:条目 → 路由 → catalog)—— 可以让把 catalog 路由改指到自家网关的部署,一句话声明「这里没有视觉能力」。被否决是因为同一句话也会在有人照着容量字段类比写下它的路由上,悄悄禁用每一个 catalog 视觉模型;而那个正当场景由该模型自己的 `input` 承担。覆盖值还必须在路由级改名叫 `input`,因为在两个货真价实的回退值旁边把它叫作 `default*` 是名不副实。 - **完全不要路由字段,只要条目字段** —— 最贴近上游(上游没有路由级概念)。被否决的理由是产品自身流程会产生的批量场景:「获取可用模型」一次采纳三十个不带模态的 id,全是视觉模型的网关就得逐个手写 `input`。 - **只要路由级 `defaultInput`,不要条目字段** —— 无法在一条路由上混合模态,也无法修正单个 catalog 模型,唯一的变通办法只剩「把该提供方拆成两个路由键」,代价是多一个永久的 provider id 和每个模型选择器里的一项重复。 @@ -42,7 +42,7 @@ DeepSeek chat-completions 适配器保持不动。它的 `['text']` 是关于其 图片准入门禁在各处都保住了自己的意义,因为它读到的每一个模态,如今要么由已安装 catalog 记录,要么由人写下。没有任何环节会替部署宣称一项能力。 -声明了端点并不提供的图片能力的模型不会在本地被拦下——该断言不经验证——而由此产生的失败代价高昂。prompt 准入在构造请求之前就把用户消息持久化提交(`agent/inbox/spliced`),因此被拒绝的图片会留在会话日志里:该模型会不断重发它,而模型选择拒绝切换到任何纯文本模型。恢复途径是选择一个确实提供图片能力的模型、fork 到图片之前,或者开启新会话。让这次失败不具破坏性——发送失败时把尚未消费的图片消息从日志中回滚出去——才是能让乐观默认值重新可考虑的那项改动,本次未做尝试。 +声明了端点并不提供的图片能力时,本地无法发现该错误,因为声明不会被远端验证。prompt 准入会在请求构造前持久化用户消息,因此被拒绝的图片留在会话日志中,之后对该路由的请求可能再次失败。恢复方法是修正声明、选择支持图片的路由,或选择由请求投影把持久图片替换为占位符的纯文本路由。 ## 测试 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml index d5c2e38a49..a897753a57 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.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 .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md -2026-07-29-atomic-web-image-admission.md: c09d376f101a41994df3a10c22c06da4e59f06f6 -2026-07-29-atomic-web-image-admission.zh.md: 8785f7489b0c433cba43a1747533b1d38aada3d3 +2026-07-29-atomic-web-image-admission.md: dd2faf1e14c6147c80bcba571d5310899c2e8e22 +2026-07-29-atomic-web-image-admission.zh.md: 8f15f8848dcb38fe6be178b86082a72b4ff0c8eb diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md index c09d376f10..dd2faf1e14 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md @@ -6,24 +6,24 @@ English | [中文](2026-07-29-atomic-web-image-admission.zh.md) ## Problem -Image prompt admission and `session.selectModel` each read session modality state across asynchronous model and attachment lookups. Without one ordering boundary, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target, or selection could miss a prompt after inbox dequeue but before its durable message event. Scanning the immutable event log avoided the second race but permanently blocked a text-only selection even after compaction removed the image from current model history. +Image prompt admission and `session.selectModel` each cross asynchronous model and attachment lookups. Without one ordering point, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target. Selection could also change the route after admission had begun but before the durable message event was published. ## Decision -Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint. +Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot create this ordering conflict. -The pending-publication set records a queued occurrence at dequeue and a steering occurrence already at enqueue (steering items never enter the queued UI mirror), and retains each until its matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the transition to idle retires the entries; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that set, the queued UI mirror, and `Session.deriveMessages()`, which is the current model-visible history after compaction. +The chain gives the two operations a deterministic order. When selection runs first, later image admission observes the selected model and refuses an unsupported image before persistence. When image admission runs first, its attachment and event publication complete before selection changes the route. The shared LLM runtime can then project durable image blocks to deterministic text placeholders for a text-only request without rewriting the session log. Steering uses the same admission chain even though it does not enter the queued UI mirror. Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly. ## Alternatives considered -**Scan every immutable session event.** This catches published images but treats compacted-away content as permanently model-visible, preventing a valid later switch to a text-only route. +**Scan durable or derived history before selection.** This prevented a text-only route from being selected whenever history contained an image. Request-local projection now supports that route directly, so history is no longer a selection constraint. -**Retire the pending mirror at inbox dequeue.** Dequeue precedes the durable message append and leaves the exact interval in which model selection can miss both pending and published state. +**Track pending publication separately.** A queued occurrence could be retained from dequeue through its matching event. The promise chain already keeps selection behind the complete admission operation, so a second lifecycle mirror is unnecessary. **Serialize every prompt and session mutation.** Text-only prompts and unrelated session operations cannot introduce an image requirement. A broader lock would add latency and ownership without closing another modality race. ## Consequences -An image prompt and a concurrent model selection have deterministic order, and a text-only target cannot strand an image that has been admitted but not yet published. Selection may wait for an in-flight image admission, while unrelated prompts retain their existing concurrency. Compaction can make a text-only target valid once no pending or derived image remains. +An image prompt and a concurrent model selection have deterministic order. Selection may wait for in-flight image admission, while unrelated text prompts retain their existing concurrency. Text-only model selection remains available after images enter durable history because request assembly projects those images to placeholders. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md index 8785f7489b..8f15f8848d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md @@ -6,24 +6,24 @@ Status: implemented ## 问题 -包含图片的提示词准入与 `session.selectModel` 都会在跨越异步模型查询与附件查询的过程中读取会话模态状态。如果没有统一的顺序边界,包含图片的提示词可能在支持图片的目标上通过校验,并发的选择操作却设置了纯文本目标;选择操作也可能在提示词已从 inbox 出队、但其持久消息事件尚未发布时漏掉该提示词。扫描不可变事件日志可以避免第二种竞态,但即使压缩(compaction)已经从当前模型历史中移除图片,仍会永久阻止选择纯文本目标。 +包含图片的提示词准入与 `session.selectModel` 都会跨越异步模型查询和附件查询。没有统一的排序点时,包含图片的提示词可能在支持图片的目标上通过校验,并发选择却设置了纯文本目标。选择也可能在准入已经开始、持久消息事件尚未发布时改变路由。 ## 决策 -每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。 +每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会产生这类排序冲突。 -待发布集合会在排队条目出队时记录它,而 steering 条目在入队时即被记录(steering 条目从不进入排队 UI 镜像),并各自保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,转为空闲状态会移除这些条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该集合、排队 UI 镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 +该链为两个操作提供确定顺序。模型选择先执行时,后续图片准入会看到已选模型,并在持久化之前拒绝不支持的图片。图片准入先执行时,附件和事件会在模型选择改变路由之前完成发布。之后,共享 LLM 运行时可以在纯文本请求中把持久图片块投影为确定的文本占位符,无需改写会话日志。steering 不进入排队 UI 镜像,但仍使用同一条准入链。 提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。 ## 曾考虑的替代方案 -**扫描每个不可变会话事件。** 这能捕获已发布的图片,但会把经压缩移除的内容视为永久对模型可见,从而阻止之后合法切换到纯文本路由。 +**选择前扫描持久历史或派生历史。** 这会在历史包含图片时阻止选择纯文本路由。请求期投影已经可以直接支持该路由,因此历史不再是选择约束。 -**在 inbox 出队时退役待处理镜像。** 出队早于持久消息追加,因此恰好会留下一个时间区间,让模型选择既看不到待处理状态,也看不到已发布状态。 +**单独跟踪待发布状态。** 排队条目可以从出队一直保留到匹配事件发布。promise 链已经让模型选择等待完整的准入操作,因此不需要第二套生命周期镜像。 **序列化每个提示词和会话变更。** 纯文本提示词和无关的会话操作无法引入图片要求。更宽的锁会增加延迟与所有权复杂度,却不会再消除任何模态竞态。 ## 后果 -包含图片的提示词准入与并发模型选择之间具有确定的先后顺序,纯文本目标无法使已获准入但尚未发布的图片搁浅。模型选择可能等待正在进行的图片准入完成,而无关提示词仍按现有方式并发处理。当没有图片等待发布,且派生历史经过压缩后也不再含图片时,纯文本目标可以变得有效。 +包含图片的提示词准入与并发模型选择之间具有确定顺序。模型选择可能等待正在进行的图片准入完成,无关的纯文本提示词仍按现有方式并发处理。图片进入持久历史后仍可选择纯文本模型,因为请求组装会把图片投影为占位符。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml deleted file mode 100644 index ec3cea9dc2..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md -2026-08-17-image-dimension-admission-limit.md: 027259c0949d142ce8d8af27e7daa2abd54769ab -2026-08-17-image-dimension-admission-limit.zh.md: 38422615aa93b7f1639877d7d3751322c77de1eb diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md deleted file mode 100644 index 027259c094..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: Per-side image dimension admission limit - -Status: implemented - -English | [中文](2026-08-17-image-dimension-admission-limit.zh.md) - -## Problem - -`read_image` durably committed an image and appended its block to session history before any dimension check beyond byte count and total pixels. Deployed model routes reject a request with HTTP 400 when it carries many images and any of them has a side above 2000px. An admitted image rides every later request of its session, so one oversized read poisoned the durable history: the next model request failed, and so did every retry, permanently killing the session. The same gap applied to every other image producer (host uploads, MCP tool images) because admission had no per-side bound at all. - -## Decision - -`ImageAttachmentLimits` carries `maxImageDimension`, enforced during the admission full decode (`detectImage`) as `IMAGE_DIMENSION_TOO_LARGE`, so every producer that commits through the attachment service refuses an oversized image before anything reaches durable history. `LocalAttachmentStore` exposes it as the `maxImageDimension` config field with default `DEFAULT_MAX_IMAGE_DIMENSION = 2000`, the strictest per-side bound deployed routes enforce; deployments with laxer routes raise it from cordis.yml. `read_image` maps `IMAGE_DIMENSION_TOO_LARGE` and `IMAGE_TOO_MANY_PIXELS` to model-facing errors that name the resolved path and the limit and tell the model to downscale and retry — the turn continues as a recoverable tool error. The Web composer surfaces `IMAGE_DIMENSION_TOO_LARGE` with dedicated copy naming the limit. The `read-image-dimension` snapshot scenario replays the refusal keylessly through the assembled app: a 2001x1 workspace fixture, a recoverable tool error, and a completed turn. - -## Alternatives considered - -- **Downscale at admission instead of refusing.** Resampling changes the stored bytes away from what the caller supplied, adds a resampling-quality policy, and hides the limit from the model. Refusal keeps admission a pure gate; the model or user can downscale with full knowledge. Worth revisiting only if refusals prove frequent in practice. -- **Enforce at the provider adapter per route.** Too late: by the time a request is assembled the image is already durable history, so every route and every retry re-fails. Admission is the last point where a provider-rejected image can be kept out. -- **Repair already-poisoned sessions** (drop or replace the oversized block on later requests). Out of scope for this fix; admission prevents new poisonings, and history rewriting needs its own design against the model-visible ⟺ logged invariant. - -## Related - -- [Minimal read_image tool](../feature/2026-08-10-minimal-read-image-tool.md) — the tool whose admission gap this closes. -- [Web image intake and limits alignment](../feature/2026-08-12-web-image-intake-and-limits-alignment.md) — the composer-side surfacing of the same `ImageAttachmentLimits`. - -## Consequences - -- One oversized `read_image` can no longer break a session; the model sees an actionable error and the turn completes. -- Images with a side above 2000px are refused even in compositions whose routes would accept them on small requests; such deployments must raise `maxImageDimension` explicitly. -- Sessions that already carry an oversized image remain broken; this change does not repair existing history. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md deleted file mode 100644 index 38422615aa..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: 图片单边尺寸准入上限 - -Status: implemented - -[English](2026-08-17-image-dimension-admission-limit.md) | 中文 - -## Problem - -`read_image` 在字节数与总像素之外没有任何尺寸检查,就把图片持久提交并追加进会话历史。已部署的模型路由在请求携带多张图片且其中任何一张单边超过 2000px 时会以 HTTP 400 拒绝整个请求。已接纳的图片会随该会话之后的每次请求发送,因此一次超限读取就毒化了持久历史:下一次模型请求失败,之后的每次重试同样失败,会话被永久杀死。其他图片来源(宿主上传、MCP 工具图片)存在同样的缺口,因为准入完全没有单边上限。 - -## Decision - -`ImageAttachmentLimits` 增加 `maxImageDimension`,在准入完整解码(`detectImage`)中以 `IMAGE_DIMENSION_TOO_LARGE` 强制执行,因此所有经附件服务提交的来源都会在任何内容进入持久历史之前拒绝超限图片。`LocalAttachmentStore` 将其暴露为 `maxImageDimension` 配置项,默认值 `DEFAULT_MAX_IMAGE_DIMENSION = 2000`,即已部署路由强制执行的最严格单边上限;路由更宽松的部署可在 cordis.yml 中调高。`read_image` 把 `IMAGE_DIMENSION_TOO_LARGE` 与 `IMAGE_TOO_MANY_PIXELS` 映射为面向模型的错误,指明解析后的路径与上限并提示缩图重试,本轮以可恢复的工具错误继续。Web 输入框对 `IMAGE_DIMENSION_TOO_LARGE` 给出指明上限的专用文案。`read-image-dimension` 快照场景通过组装后的应用无 key 回放这次拒绝:2001x1 的工作区 fixture、一条可恢复的工具错误、一个正常完成的轮次。 - -## Alternatives considered - -- **准入时缩图而非拒绝。** 重采样会让存储字节偏离调用方提供的内容,引入重采样质量策略,还会对模型隐藏上限。拒绝让准入保持为纯粹的门禁;模型或用户可以在知情的前提下自行缩图。只有当拒绝在实践中频繁出现时才值得重新考虑。 -- **在 provider 适配器按路由强制执行。** 为时已晚:组装请求时图片已是持久历史,每条路由、每次重试都会再次失败。准入是把必然被上游拒绝的图片挡在外面的最后一道关口。 -- **修复已被毒化的会话**(在之后的请求中丢弃或替换超限图片块)。不在本次修复范围内;准入阻止新的毒化,而重写历史需要针对「模型可见 ⟺ 已记录」不变量单独设计。 - -## Related - -- [最小 read_image 工具](../feature/2026-08-10-minimal-read-image-tool.zh.md),本次修复补上的正是该工具的准入缺口。 -- [Web 图片摄入与限制对齐](../feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md),同一组 `ImageAttachmentLimits` 在输入框侧的呈现。 - -## Consequences - -- 一次超限的 `read_image` 不再能弄坏会话;模型看到可操作的错误,轮次正常完成。 -- 单边超过 2000px 的图片即使在其路由本可接受(小请求)的组合中也会被拒绝;这类部署必须显式调高 `maxImageDimension`。 -- 已经携带超限图片的会话仍然是坏的;本次改动不修复既有历史。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml deleted file mode 100644 index 8e355b809e..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md -2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1 -2026-08-18-request-image-payload-bound.zh.md: 7cdf6bb768251cb792b6d590fafa094646e77ada diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md deleted file mode 100644 index 0ec4594888..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +++ /dev/null @@ -1,36 +0,0 @@ -# Agent Note: Request-level image payload bound - -Status: implemented - -English | [中文](2026-08-18-request-image-payload-bound.zh.md) - -## Problem - -Every image in session history is base64-inlined into every model request by the pi-ai adapter, so a long session's request body grows monotonically with each admitted image. Gateways cap request-body size; once the accumulated payload crossed such a cap the request was rejected with 413 (`Failed to buffer the request body: length limit exceeded`), and because nothing bounds or trims the assembled request, every retry resent the same oversized body. The session was permanently unusable, and the failure text matched no `classifyPiAiError` rule, so it surfaced as the generic `PI_AI_ERROR`. Admission bounds (per image, per message) cannot prevent this: each image is individually admissible, and the sum still grows without bound. Two screenshots were enough to trigger it in production. - -## Decision - -The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route. - -## Offload is conversion, not history - -The placeholder is model-visible but not logged as a session event. It stays within the model-visible ⟺ logged invariant the same way the adapter's other serialization does (`(no output)` fallbacks, text-only folding): the offload locations are a pure function of the logged history and the route configuration, so the exact request remains reconstructable from the session log plus the composition. A logged elision event becomes necessary only when offload decisions gain non-deterministic inputs (for example live gateway feedback), which belongs to the deferred capability-metadata design. - -## Alternatives considered - -- **Fail the request with a clear error instead of offloading.** Keeps the model informed but leaves the session wedged: the user cannot remove images from durable history, so a hard failure at the bound is permanent. Offload keeps the session serviceable, which is the point of the fix. -- **Upload images once and reference them by URL / file id.** Removes the linear body growth entirely and is the right medium-term shape (providers and the internal gateway both document a Files path), but it introduces upload lifecycle management across providers and is far beyond a P0 hotfix. -- **Count the full request body, not only images.** Text and tools contribute little and their sizes are only known after full serialization per protocol; bounding the dominant term with explicit headroom is accurate enough for the failure being fixed and much simpler. Revisit inside the route-capability design. -- **Trim at admission instead.** Admission cannot see future accumulation; only the assembled request knows its total. Admission-side bounds (per-side dimension, bytes) remain as the first layer and are owned by [the dimension-limit note](2026-08-17-image-dimension-admission-limit.md). - -## Related - -- [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size). -- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route. - -## Consequences - -- An image-heavy long session keeps completing requests. The oldest images are omitted first; the most recent image is omitted only when it cannot fit within the bound. -- Crossing the bound rewrites an early message, so the provider prompt-cache prefix ends at the newly offloaded image until the offloaded prefix stabilizes. -- The bound counts base64 image payload only; deployments must keep it below their gateway's request-body cap with headroom, and the shipped default cannot know a private gateway's cap. -- Route capability metadata driving admission and assembly together (image count, per-image size, request size, provider token formulas) remains deferred design work tracked outside this fix. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md deleted file mode 100644 index 7cdf6bb768..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md +++ /dev/null @@ -1,36 +0,0 @@ -# Agent Note: 请求级图片载荷上限 - -Status: implemented - -[English](2026-08-18-request-image-payload-bound.md) | 中文 - -## Problem - -pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型请求,长会话的请求体随每张入库图片单调增长。网关对请求体大小设有上限;累积载荷一旦越线,请求被以 413 拒绝(`Failed to buffer the request body: length limit exceeded`),而组装层没有任何约束或裁剪,每次重试都会原样重发同一个超限请求体,会话永久不可用。该报错文本不匹配 `classifyPiAiError` 的任何规则,只能落进笼统的 `PI_AI_ERROR`。准入上限(单图、单消息)无法阻止这一点:每张图片单独看都合规,总和仍然无界增长。线上两张截图即可触发。 - -## Decision - -pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。 - -## offload 是转换而非历史 - -占位文本模型可见,但不记录为会话事件。它与适配器的其他序列化(`(no output)` 回退、纯文本折叠)以同样的方式满足「模型可见 ⟺ 已记录」不变量:offload 位置是已记录历史与路由配置的纯函数,确切请求仍可由会话日志加组合配置重建。只有当 offload 决策引入非确定性输入(例如网关的实时反馈)时才需要记录省略事件,那属于暂缓的能力元数据设计。 - -## Alternatives considered - -- **在上限处直接报错而不 offload。** 模型知情,但会话仍然卡死:用户无法从持久历史中删除图片,越线即永久失败。offload 让会话保持可用,这正是本修复的目标。 -- **图片上传一次、按 URL / file id 引用。** 从结构上消除请求体线性增长,是正确的中期形态(各提供方与内部网关都有 Files 路径),但要跨提供方管理上传生命周期,远超 P0 热修复范围。 -- **统计完整请求体而非只统计图片。** 文本与工具占比很小,且其大小要到按协议完整序列化后才可知;对主导项设上限并留出显式余量,对所修故障足够精确且简单得多。留到路由能力设计中再议。 -- **改在准入侧裁剪。** 准入看不到未来的累积,只有组装后的请求知道自己的总量。准入侧上限(单边尺寸、字节)作为第一层保留,归[尺寸上限笔记](2026-08-17-image-dimension-admission-limit.zh.md)所有。 - -## Related - -- [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.zh.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。 -- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.zh.md)把这项提供方无关转换应用于官方多模态路由。 - -## Consequences - -- 图片较多的长会话持续可用。最老的图片优先省略;仅当最新图片本身无法装进上限时才会省略它。 -- 越过上限会改写较早的一条消息,提供方 prompt cache 前缀在新被 offload 的图片处截止,直到被 offload 的前缀稳定。 -- 上限只统计 base64 图片载荷;部署必须让它低于自家网关的请求体上限并留出余量,发行默认值无法预知私有网关的上限。 -- 由路由能力元数据同时驱动准入与组装(图片数量、单图大小、请求大小、提供方 token 公式)的设计仍为暂缓工作,在本修复之外跟踪。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 55710b6e95..be716462f5 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 3f77ab8d55f8eca821cd12a4591c6239c2ea10f5 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: c95c5abe664635f3cde3a1fc2d569c9474c69665 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 30ac1dcff9e6400a3bcf58f7b8e5237e20bd5c04 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 68c370c3dd2234e67717429bed417755ed20305d diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 3f77ab8d55..30ac1dcff9 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -69,7 +69,7 @@ interface ComposerAttachment { This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A Workspace switch moves a mixed text-and-image draft only when the destination shell accepts the complete image batch; refusal leaves both parts with the source. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. +The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission prepares a provider-independent master by applying orientation, removing metadata, converting to 8-bit sRGB/sRGBA, and preserving aspect ratio under independent dimension and byte limits. Reads verify the digest, byte length, and logged metadata. Route-specific deterministic request versions are cached separately; the full policy is recorded in [Unified image masters, request versions, and provider files](2026-08-20-unified-image-request-pipeline.md). The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. @@ -114,7 +114,7 @@ type PromptInputPart = } ``` -Base64 crosses a wire boundary once and is discarded after persistence. Each front door validates canonical base64 and declared MIME shape, then calls `AttachmentStore.saveImages()` with the whole decoded batch. The service owns image count, aggregate bytes, individual bytes, fully decoded raster/MIME agreement, intrinsic dimensions, and decoded-pixel count; it validates every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the caller appends no model-visible event and receives no partial references, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does the front door call the agent with normalized text and durable image blocks in wire order. A failure exposes no attachment path or raw bytes. +Base64 crosses a wire boundary once and is discarded after persistence. Each front door validates canonical base64 and declared MIME fields, then calls `AttachmentStore.saveImages()` with the whole decoded batch. The service owns image count, aggregate bytes, individual bytes, fully decoded raster/MIME agreement, intrinsic dimensions, decoded-pixel count, and master preparation. It prepares and verifies every batch member once before publishing any member, so one malformed image cannot create partial references and large images are not decoded and encoded again at commit. Storage commits then run in submission order. If a later storage I/O operation fails, the caller appends no model-visible event and receives no partial references, but an earlier immutable content-addressed object may remain unreferenced under the existing storage rule. Only after every image succeeds does the front door call the agent with normalized text and durable image blocks in wire order. A failure exposes no attachment path or raw bytes. `session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache. @@ -122,15 +122,15 @@ Base64 crosses a wire boundary once and is discarded after persistence. Each fro Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. +The host is the authoritative preflight point. It resolves the session's latest routed provider and model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects a new image prompt before writing an attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial chain ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)), including steering that does not enter the queued UI mirror. This gives a prompt and concurrent selection a deterministic order. Selection itself may target a text-only model after images enter durable history; the shared LLM runtime replaces retained image blocks with deterministic text placeholders for that request. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past admission. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing an attachment or event; its rejection appears through the composer's transient toast. -Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route advertises `deepseek-v4-flash-vision-exp` as image-capable and accepts configured image-capable catalog entries; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. +Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each retained image reference including references nested inside tool results, and emit native image content only for models that declare image input. Both adapters request the same deterministic route-specific version from the durable normalized attachment. Pi-AI carries it inline under a base64-aware request budget. The built-in DeepSeek route advertises `deepseek-v4-flash-vision-exp`, uploads every retained version through Files API, and sends `file_id` blocks with indexed reuse, expiry, bounded stale-id retry, quota cleanup, and explicit deletion. DeepSeek text models, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or silently skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; Code Mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context. -Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compaction-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. +Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route uses the same deterministic request versions as ordinary turns. A text-only route receives the same deterministic attachment placeholders as any other LLM request. The synthesized checkpoint remains text-only, and `compaction-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. ### History rendering and original preview @@ -140,7 +140,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 3.5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 40 million intrinsic pixels per image, and 2000 pixels on either side. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Source intake defaults are 32 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 100 million decoded pixels per image, and 16384px on either side. The provider-independent master defaults to a 2048px long edge and 4 MiB safety cap. Provider request pixel and encoded-byte limits are separate route policies. These deployment-varying limits are validated backend configuration and enforced before persistence or request transmission. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap, 160 MiB by default, and fails load if it cannot hold the aggregate source limit after base64 and envelope expansion. A body without a declared length is rejected when it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, excess per-side dimensions, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. @@ -148,11 +148,11 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | Surface | Responsibility | | --- | --- | -| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and single/batch admission through `ctx.attachments`. | -| `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | -| `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | -| `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | -| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. | +| `packages/attachment/attachment` | Opaque attachment and request-version identifiers, image references, policies, failures, batch admission, derived reads, and crops through `ctx.attachments`. | +| `packages/attachment/attachment-local` | Private content-addressed masters, deterministic request cache, complete raster decoding, integrity verification, and configuration. | +| `packages/llm/llm` | Role-neutral `ImageBlock`, input-modality metadata, exact adapter generations, and text-only request projection. | +| `packages/llm/llm-pi-ai` | Resolve durable images to deterministic inline request versions. | +| `packages/llm/llm-deepseek` | Resolve official vision input to deterministic request versions and Files API ids. | | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. | | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | @@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. +The implemented capability includes shared prepare-once batch admission, provider-independent masters, deterministic request versions, DeepSeek Files reuse, stable crop handles, role-neutral image blocks, Pi-AI and DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image support, lossless MCP results with durable image projection, Code Mode rich-result forwarding, bounded Web requests, draft and historical image UI, compaction handling, and keyless assembled coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -210,11 +210,11 @@ Rejected because tool renderers are pure, synchronous, and replayable. MCP prepa ## Testing - Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. -- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection ordering, text-only queue edits, and text-only request projection. - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. -- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection. +- Adapter and compaction tests cover deterministic Pi-AI request versions, DeepSeek Files upload and reuse, stale-id recovery, text-only projection, recursively nested tool-result images, shared summary request versions, and explicit image-output rejection. - Attachment, MCP, ACP, and Code Mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log. -- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. +- Credentialed real-API tests cover the configured Anthropic route and the built-in `deepseek-official` Files path. The DeepSeek test does not use a custom provider entry. - The current production adapter set has no certified image-output route; output-provider certification remains outside version one. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index c95c5abe66..68c370c3dd 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -69,7 +69,7 @@ interface ComposerAttachment { 这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时,只有目标外壳接受完整图片批次,图文混合草稿才会移动;拒绝时,文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。准入会应用方向、删除元数据、转换为 8-bit sRGB/sRGBA,并在独立尺寸和字节上限内保持宽高比,生成与提供方无关的主版本。读取会校验摘要、字节长度和已记录元数据。路由专用的确定性请求版本单独缓存,完整策略见[统一图片主版本、请求版本和提供方文件](2026-08-20-unified-image-request-pipeline.md)。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -114,7 +114,7 @@ type PromptInputPart = } ``` -Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都会校验规范 base64 与声明的 MIME 形状,再用完整解码批次调用 `AttachmentStore.saveImages()`。服务负责图片数量、总字节数、单张图片字节数、声明 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数;它会在保存任何成员之前校验每个批次成员,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,调用方不会追加模型可见事件,也不会收到部分引用,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,入口才会用规范化文本和按协议顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 +Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都会校验规范 base64 与声明的 MIME 字段,再用完整解码批次调用 `AttachmentStore.saveImages()`。服务负责图片数量、总字节数、单张图片字节数、声明 MIME 与完整解码后的光栅图片是否一致、固有尺寸、解码像素数和主版本准备。它会在发布任何成员之前只准备并验证每个批次成员一次,因此一张畸形图片不会产生部分引用,大图也不会在提交时重复解码和编码。随后按顺序提交存储。如果后续存储 I/O 操作失败,调用方不会追加模型可见事件,也不会收到部分引用,但先前的不可变内容寻址对象可能按现有存储规则保持无引用状态。只有每张图片都成功后,入口才会用规范化文本和按协议顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。 @@ -122,15 +122,15 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.zh.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 +宿主是权威的前置检查点。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果模型明确排除图片输入,宿主会在写入附件或事件前拒绝新的图片提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一条逐 agent 串行链([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md)),也包括不进入排队 UI 镜像的 steering。这会为提示词和并发选择提供确定顺序。图片进入持久历史后仍可选择纯文本模型;共享 LLM 运行时会在该请求中把保留的图片块替换为确定的文本占位符。`session.updateQueue` 只接受文本内容,因此队列编辑无法绕过准入注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入附件或事件;拒绝会通过 composer 的短时 toast 显示。 -Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由会将 `deepseek-v4-flash-vision-exp` 公布为支持图片,并接受已配置且支持图片的 catalog 配置项;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 +Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个保留的图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。两个适配器都从持久主版本请求同一个确定性路由版本。Pi-AI 在考虑 base64 扩张的请求预算内内联携带它。内置 DeepSeek 路由公布 `deepseek-v4-flash-vision-exp`,把每个保留的版本上传到 Files API,并通过索引复用、过期处理、有界陈旧 ID 重试、配额清理和显式删除发送 `file_id` 块。DeepSeek 纯文本模型、未声明图片能力的自定义模型和未列出的透传 ID 保持纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。适配器不得展平或静默跳过保留图片;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;Code Mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。 -压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compaction-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 +压缩会把选定的会话前缀和其中的图片引用回放到已配置的摘要生成路径。支持视觉的路径使用与普通轮次相同的确定性请求版本。纯文本路径接收与其他 LLM 请求相同的确定性附件占位符。合成的检查点仍仅包含文本,`compaction-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 ### 历史渲染与原图预览 @@ -140,7 +140,7 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 3.5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片 4,000 万个固有像素,以及任一边 2,000 像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。源文件输入默认限制为每张图片 32 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片一亿解码像素,以及任一边 16384px。与提供方无关的主版本默认长边 2048px,独立安全上限 4 MiB。提供方请求的像素和编码字节上限是单独的路由策略。这些随部署变化的限制属于经过校验的后端配置,并在持久化或请求发送前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限,默认 160 MiB;如果该上限无法容纳源文件总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。未声明长度的请求体在越过上限时即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、超出单边尺寸限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 @@ -148,11 +148,11 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, | 接口 | 职责 | | --- | --- | -| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误,以及通过 `ctx.attachments` 提供的单张/批量准入。 | -| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | -| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | -| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | -| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 | +| `packages/attachment/attachment` | 不透明附件和请求版本标识符、图片引用、策略、错误,以及通过 `ctx.attachments` 提供的批量准入、派生读取和裁剪。 | +| `packages/attachment/attachment-local` | 私有内容寻址主版本、确定性请求缓存、完整光栅解码、完整性校验和配置。 | +| `packages/llm/llm` | 角色无关的 `ImageBlock`、输入模态元数据、精确适配器代次和纯文本请求投影。 | +| `packages/llm/llm-pi-ai` | 把持久图片解析为确定性内联请求版本。 | +| `packages/llm/llm-deepseek` | 把官方视觉输入解析为确定性请求版本和 Files API ID。 | | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 | | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | @@ -165,7 +165,7 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, ### 实现 -已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 +已实现能力包括只准备一次的共享批量准入、与提供方无关的主版本、确定性请求版本、DeepSeek Files 复用、稳定裁剪句柄、角色无关图片块、Pi-AI 和 DeepSeek 输入转换、Web/ACP/MCP 持久化顺序、Web 上传与读取协议、条件式 ACP 图片支持、带持久图片投影的无损 MCP 结果、Code Mode 丰富结果转发、有界 Web 请求、草稿与历史图片 UI、压缩处理,以及组装后的无密钥覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -210,11 +210,11 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的排序、仅文本的队列编辑,以及纯文本请求投影。 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 -- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 适配器与压缩测试覆盖确定性 Pi-AI 请求版本、DeepSeek Files 上传与复用、陈旧 ID 恢复、纯文本投影、递归嵌套在工具结果中的图片、共享摘要请求版本,以及明确拒绝图片输出。 - 附件、MCP、ACP 与 Code Mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。 -- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 +- 需要凭据的实际 API 测试会覆盖配置的 Anthropic 路由和内置 `deepseek-official` Files 路径。DeepSeek 测试不使用自定义提供方条目。 - 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml index dcd01fc6d3..6c37530274 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md -2026-08-10-minimal-read-image-tool.md: a43e53d70e98bac7a50aa6bbabbb1e177237df01 -2026-08-10-minimal-read-image-tool.zh.md: a94e4b296425ad50876b0b45a689442c896a85a1 +2026-08-10-minimal-read-image-tool.md: 0c0c6a95fa3d8be1dbe895ecd83ff44e1e1eac17 +2026-08-10-minimal-read-image-tool.zh.md: c3c2fe1095637a19c3ebaa21cf23a501fe83c480 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md index a43e53d70e..0c0c6a95fa 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -6,28 +6,29 @@ English | [中文](2026-08-10-minimal-read-image-tool.zh.md) ## Problem -The multimodal attachment work gave user uploads a complete durable path — bytes committed to the content-addressed attachment store before the owning `user/message`, an `ImageBlock` carrying only the `sha256:` reference, and the pi-ai route re-reading verified bytes per request — but the model itself had no way to look at an image on disk. `read` rejects binary content by contract, so an agent asked about a screenshot or a rendered chart either failed or shelled out to lossy workarounds. A first standalone attempt (PR #598) solved this together with loop-level route scoping: an `agent/request-ready` extension point publishing exact-model modalities before assembly, per-route schema/guidance visibility, and a reversible `image-placeholder-v1` history projection so text routes could continue over placeholder text. That design worked but coupled a tool to new agent-loop machinery, three new session-log concepts, and per-step registration churn — far more surface than the capability needs. +The multimodal attachment work gave user uploads a complete durable path, but the model itself had no way to inspect an image on disk or crop a durable user upload that had no path. `read` rejects binary content by contract, so an agent asked about a screenshot or rendered chart either failed or used a lossy workaround. A standalone attempt in PR #598 combined the tool with loop-level route scoping, per-route schema visibility, and new session-log concepts. Those features were not required to publish a logged image tool result. ## Decision -Ship the smallest tool that loads an image into the next request's context, entirely over existing seams; the withdrawn PR #598 design is the explicit counter-example this note records. +Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged tool results over existing extension points. -- **`read_image` lives in `dsh-tool-fs`** beside `read`/`write`/`edit`. Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`, and the tool result is the metadata envelope plus a real `ImageBlock` — `ToolResultBlock.content` already admits image blocks, the pi-ai adapter already renders them, and the Web host's model-switch guard already scans tool results, so nothing downstream changes. +- **`read_image` reads a filesystem path.** Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`. The tool result contains metadata and an `ImageBlock`. +- **`read_image_region` crops a durable session attachment.** The request names the complete attachment id, current preview dimensions, and a preview-coordinate rectangle. The tool authorizes the id against images already referenced by the calling session, maps the rectangle to the durable master, crops that master, and persists the result as a new attachment. Its result contains the cropped `ImageBlock`, so the model-visible crop is reconstructable from the log. This is the path for pasted or dragged images that have no filesystem location. - **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`). -- **Registration is composition-conditional, execution is route-gated.** The tool registers only under `ctx.inject(['attachments'], …)` — no store, no tool. At execution, before any I/O, the strict gate resolves the calling route (latest `request/header` config, falling back to agent options) through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A refusal is a plain `isError` result, so a text route's durable history never acquires an image block and the session cannot brick its own route. +- **Registration is composition-conditional, execution is route-gated.** The tools register only under `ctx.inject(['attachments'], …)`. Before I/O, the strict gate resolves the calling route through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A text-only route can still consume prior durable images because the shared LLM runtime projects them to placeholders at request assembly. - **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. -- **llm-replay models may declare `inputModalities`**, which is what lets the two keyless ACP snapshots pin both sides of the gate — the sha256-referenced success on an image-capable replay route and the verbatim refusal on a text-only one. +- **llm-replay models may declare `inputModalities`**, which lets keyless ACP snapshots cover the image-capable result and the text-only refusal. ## Alternatives considered -- **PR #598's route-scoped design** (request-ready seam, per-route schema/guidance visibility, reversible history projection) — withdrawn in favor of this note's shape. What it bought: text routes could keep running after images entered history, and the tool disappeared from prompts where it cannot succeed. What it cost: agent-loop changes, three new durable concepts (`agent/request-ready`, `messageProjection`, availability notices), and registration that churned per step. The capability itself — see an image on the next request — never needed any of it. If per-route projection becomes a real requirement, that PR's history is the reference implementation. +- **PR #598's route-scoped design** used a request-ready extension point, per-route schema visibility, reversible projection, and three durable concepts. Shared LLM request projection now handles text-only routes without putting tool registration or session formats into agent-loop. - **`agent.inject()` instead of the image-bearing tool result** — routes the image around the tool result as a separate injected user message. Rejected: the image *is* the tool's result; splitting them adds a second logged message with no gain, and the tool-result path already works end to end. - **Magic-byte sniffing instead of extension declaration** — sniffing duplicates detection the attachment store already owns (sharp-backed, authoritative). The extension is only a *declaration*; a mismatch fails closed with a rename remedy rather than being silently accepted, which also keeps the model's mental map (file name ↔ content) honest. - **Registering unconditionally and failing on a missing store** — rejected; a deployment without an attachment store cannot ever satisfy the tool, so its schema would be a standing lie. The route gate, by contrast, is per-call state and correctly lives at the execution boundary. ## Consequences -- A text-only route refuses instead of degrading: no placeholder projection means no delegated-viewing story here — that is deliberately the next PR (subagent image readback rebuilt on the current subagent seams). -- The route gate races a concurrent model switch; the Web host's image-aware switch guard covers its surface, and other front doors own their equivalent. Recorded as a tool-fs Known Limitation. -- Repeated image results accumulate request-token cost until compaction; content addressing deduplicates bytes only. +- The tools refuse execution on a text-only route, while existing images in session history are represented by request-local placeholders. +- Pasted and dragged images can be cropped without exposing local paths. Session reference authorization prevents access to attachments outside the current session. +- Repeated image results accumulate request cost until request projection or compaction removes them; content addressing deduplicates durable bytes. - The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages. diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md index a94e4b2964..c3c2fe1095 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -6,28 +6,29 @@ Status: implemented ## 问题 -多模态附件工作为用户上传建立了完整的持久路径:字节在所属 `user/message` 之前提交到内容寻址的附件存储,`ImageBlock` 只携带 `sha256:` 引用,pi-ai 路由在每次请求时重新读取并校验字节。但模型自己没有查看磁盘图像的手段。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么退到有损的变通做法。第一次独立尝试(PR #598)把这个问题与循环级路由作用域一起解决:新增在组装前发布确切模型模态的 `agent/request-ready` 扩展点、按路由控制 schema/指导可见性,以及可逆的 `image-placeholder-v1` 历史投影让文本路由能在占位符上继续。该设计可行,但让一个工具耦合了新的 agent-loop 机制、三个新的会话日志概念和每步的注册变动,远超这项能力本身的需要。 +多模态附件工作为用户上传建立了完整的持久路径,但模型无法查看磁盘图片,也无法裁剪没有文件路径的持久用户上传。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么使用有损的变通方法。PR #598 的独立尝试把工具与循环级路由作用域、按路由控制 schema 可见性和新的会话日志概念放在一起。这些能力不是发布一条带图片且已记录的工具结果所必需的。 ## 决定 -只交付能把图像载入下一次请求上下文的最小工具,完全建立在既有 seam 之上;撤回的 PR #598 设计是本记录明确保留的反例。 +两个图片读取操作都放在 `dsh-tool-fs`,通过现有扩展点发布普通的持久工具结果。 -- **`read_image` 放在 `dsh-tool-fs`**,与 `read`/`write`/`edit` 并列。扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型;附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动,工具结果是元数据信封加真正的 `ImageBlock`——`ToolResultBlock.content` 本就允许图像块,pi-ai 适配器本就会渲染它们,Web 宿主的模型切换防护本就会扫描工具结果,下游无需任何改动。 +- **`read_image` 读取文件系统路径。** 扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型,附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动。工具结果包含元数据和一个 `ImageBlock`。 +- **`read_image_region` 裁剪会话中的持久附件。** 请求给出完整附件 ID、当前预览尺寸和预览坐标矩形。工具根据当前会话已引用的图片授权该 ID,把矩形映射到持久主版本,从主版本裁剪,并把结果保存为新附件。结果包含裁剪后的 `ImageBlock`,因此模型可见裁剪可以从日志重建。这也是粘贴或拖入且没有文件路径的图片所使用的入口。 - **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上,任何后端都无法无界缓冲文件;stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。 -- **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册——没有存储就没有工具。执行时在任何 I/O 之前,严格门禁通过 `ctx.llm.resolveModelInfo` 解析调用路由(最新 `request/header` 配置,缺失时回退到 agent 选项),要求 `inputModalities` 包含 `image`;能力未知即拒绝。拒绝是普通的 `isError` 结果,因此文本路由的持久历史绝不会出现图像块,会话不会毁掉自己的路由。 +- **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册。执行时在 I/O 之前通过 `ctx.llm.resolveModelInfo` 解析调用路由,并要求 `inputModalities` 包含 `image`;能力未知即拒绝。纯文本路由仍可使用此前的持久图片,因为共享 LLM 运行时会在请求组装时把图片投影为占位符。 - **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 -- **llm-replay 模型可以声明 `inputModalities`**,这正是两个 keyless ACP 快照能钉住门禁两侧的原因:图像路由上以 sha256 引用的成功结果,和纯文本路由上逐字的拒绝。 +- **llm-replay 模型可以声明 `inputModalities`**,因此 keyless ACP 快照可以覆盖支持图片的结果和纯文本拒绝。 ## 考虑过的替代方案 -- **PR #598 的路由作用域设计**(request-ready 扩展点、按路由的 schema/指导可见性、可逆历史投影)——被本记录的形态取代后撤回。它换来的是:图像进入历史后文本路由仍能运行,工具在注定失败的提示词里消失。它付出的是:改动 agent-loop、三个新的持久概念(`agent/request-ready`、`messageProjection`、可用性通知)和每步变动的注册。而这项能力本身——下一次请求看到图像——从不需要这些。如果按路由投影将来成为真实需求,该 PR 的历史就是参考实现。 +- **PR #598 的路由作用域设计**使用 request-ready 扩展点、按路由控制 schema 可见性、可逆投影和三个持久概念。共享 LLM 请求投影现在可以处理纯文本路由,无需把工具注册或会话格式放进 agent-loop。 - **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。 - **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。 - **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。 ## 后果 -- 纯文本路由得到拒绝而不是降级:没有占位符投影意味着这里没有委托查看的方案——那有意留给下一个 PR(基于当前 subagent seam 重建的 subagent image readback)。 -- 路由门禁与并发模型切换存在竞态;Web 宿主的图像感知切换防护覆盖其表面,其他前端拥有各自的等价防护。已记入 tool-fs 的已知限制。 -- 重复的图像结果在压缩之前持续累积请求 token 成本;内容寻址只去重字节。 +- 工具在纯文本路由上拒绝执行,而会话历史中已经存在的图片会由请求期占位符表示。 +- 粘贴和拖入的图片无需暴露本地路径即可裁剪。会话引用授权会阻止访问当前会话范围外的附件。 +- 重复的图片结果会累积请求成本,直到请求投影或压缩将其移除;内容寻址只去重持久字节。 - 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。 diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml index 0720d5d9ee..3c2be099df 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.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 .agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md -2026-08-12-web-image-intake-and-limits-alignment.md: 00cf7ea99d63e848c4b5839da1d97d94c9fb8464 -2026-08-12-web-image-intake-and-limits-alignment.zh.md: 7bf7f3621d6d305baf8e7c1c060bbc5810f28b77 +2026-08-12-web-image-intake-and-limits-alignment.md: 0bb8cadc8db4b4c28cf311bc9420c32744e173fb +2026-08-12-web-image-intake-and-limits-alignment.zh.md: fafa7652756554a54a0a0952e843bf5f4b81b00d diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md index 00cf7ea99d..0bb8cadc8d 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md @@ -16,7 +16,7 @@ The second alignment step for issue #2248, after the [attachment display note](2 **History thumbnails (DeepSeek Chat rules).** A message's lone image renders at 240px on its long edge with the displayed ratio clamped to [0.25, 4], cropped by `cover` with the anchor at the top of very tall images and the left of very wide ones, never upscaled; several images render as fixed 64px square tiles in one wrapping row (10px gap, user messages right-aligned). Consecutive assistant `image` blocks merge into one gallery so they tile instead of each opening a one-image row. -**Limits aligned and projected.** Defaults are 20 images / 3.5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. Including base64 padding, a 3.5 MiB encoded file occupies at most 4.67 MiB and leaves 0.33 MiB below a 5 MiB route check. Deployments using only routes with larger limits can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports. +**Limits aligned and projected.** Intake defaults are 20 images, 32 MiB per source, 100 MiB aggregate source bytes, 100 million decoded pixels, and 16384px per source side. The attachment backend prepares a separate durable master with a 2048px long edge and 4 MiB safety cap. Model requests have their own route-specific pixel and encoded-byte budgets, so source admission does not use provider request limits. The HTTP carrier uses one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` to satisfy the load-time capacity assertion for the 100 MiB aggregate after base64 and envelope expansion. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would require a JSON string near V8's string-size limit. The intake limits reach clients as the `imageLimits` session projection, a constant-per-boot unit registered by **apiproxy** rather than the attachment Service Definition. `dsh-llm` depends on `dsh-attachment`, while `dsh-session-projection` reaches `dsh-llm` through `dsh-session`; registering the projection in the seam package would create a project-reference cycle. The per-message count and aggregate rules are also enforced by the proxy. The `SessionProjectionMap` merge remains in the proxy sessions wire file, which clients already consume through carrier type re-exports. **Intake pre-check and error copy.** Both intake gestures converge on one `intakeImages` wrapper in InputBar that checks count, per-image bytes, and aggregate bytes against the projection before `addImages`: a violating batch is refused whole (DeepSeek Chat semantics) with an immediate banner naming the limit — no submit-time rollback theater. The host checks stay as the backstop for callers that bypass the composer. Banner copy follows one principle the user set: reasons a user can act on (model without vision, count, size, resolution, format — now a positive list of supported formats instead of echoing the rejected MIME type) get product sentences naming the way out; reasons they cannot act on (corrupt base64, lost references, read failures) fold into one send-failed sentence that keeps the reason code, because the product currently faces developers and a reportable code beats a dead end. Non-attachment error codes keep the raw message + code presentation. diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md index 7bf7f3621d..fafa765275 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md @@ -16,7 +16,7 @@ issue #2248 的第二步对齐,接在[附件展示 note](2026-08-11-web-attach **历史缩略图(DeepSeek Chat 规则)。** 一条消息仅有的一张图长边 240px、展示比例钳制在 [0.25, 4],`cover` 裁切,特别高的图锚定顶部、特别宽的锚定左侧,从不放大;多张图渲染为固定 64px 方块,单个可换行的横排(10px 间距,用户消息右对齐)。assistant 连续的 `image` 块合并进同一个画廊,平铺而不是各占一行。 -**上限对齐并投影。** 默认值为每条消息 20 张、单图 3.5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。3.5 MiB 编码文件包括 base64 填充在内最多占 4.67 MiB,在 5 MiB 路由检查下保留 0.33 MiB 余量。仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。 +**上限对齐并投影。** 输入默认值是每条消息 20 张、每个源文件 32 MiB、源文件总量 100 MiB、每张图片一亿解码像素,以及源文件任一边 16384px。附件后端另行生成长边 2048px、独立安全上限 4 MiB 的持久主版本。模型请求使用各路由自己的像素和编码字节预算,因此源文件准入不采用提供方请求限制。HTTP 载体统一使用 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`,满足 100 MiB 总量经过 base64 和请求封装扩张后的加载时容量断言。512 MiB 总量无法通过当前传输,因为 base64 进入 JSON 后会需要一个接近 V8 字符串大小上限的 JSON 字符串。输入上限通过 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元,由 **apiproxy** 而非 attachment Service Definition 注册。`dsh-llm` 依赖 `dsh-attachment`,而 `dsh-session-projection` 经 `dsh-session` 到达 `dsh-llm`;在 seam 包注册投影会形成 project-reference 环。每条消息的数量和总量规则也由 proxy 强制执行。`SessionProjectionMap` 合并继续放在 proxy 的 sessions 协议文件中,客户端已经通过载体类型再导出使用它。 **加入预检与错误文案。** 两种加入手势汇合到 InputBar 的一个 `intakeImages` 包装:在 `addImages` 之前按投影检查数量、单图字节与总字节,违规的一批整体拒收(DeepSeek Chat 语义)并立刻弹出点名上限的横幅——不再有提交时的回滚戏码。宿主检查保留,兜底绕过 composer 的调用方。横幅文案遵循用户定下的一条原则:用户能解决的原因(模型不支持视觉、数量、大小、分辨率、格式——格式改为正面列出支持列表而不是回显被拒的 MIME 类型)用点明出路的产品句子;用户无法解决的原因(base64 损坏、引用丢失、读取失败)折叠为一条保留原因码的发送失败句子,因为产品当前面向开发者,可上报的码好过死胡同。非附件错误码保留原文加错误码的展示。 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md deleted file mode 100644 index 76d3244e67..0000000000 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: Direct DeepSeek vision input - -Status: implemented - -English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md) - -## Problem - -DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider. - -## Decision - -The shipped catalog declares `deepseek-v4-flash-vision-exp` with `inputModalities: [text, image]`; configured catalogs use the same declaration to opt another exact model into image input, and validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. - -The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O. - -The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`. - -Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported. - -## Alternatives considered - -- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id. -- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata. -- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order. -- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns. - -## Verification - -Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. A real-API smoke test with an explicit image-capable catalog entry sends a deterministic image only when `DEEPSEEK_VISION_E2E=1` is set in addition to the provider key. - -## Consequences - -The official DeepSeek vision route and configured vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available. diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md deleted file mode 100644 index a772311041..0000000000 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: 直接 DeepSeek 视觉输入 - -Status: implemented - -[English](2026-08-19-direct-deepseek-vision-input.md) | 中文 - -## Problem - -DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。 - -## Decision - -随附目录为 `deepseek-v4-flash-vision-exp` 声明 `inputModalities: [text, image]`;已配置目录可以用同一声明让另一个确切模型支持图片输入,校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。 - -适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。 - -直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.zh.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。 - -规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。 - -## Alternatives considered - -- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。 -- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。 -- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。 -- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。 - -## Verification - -包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。真实 API 冒烟测试会配置明确支持图片的目录项,并且仅在提供方密钥之外还设置 `DEEPSEEK_VISION_E2E=1` 时发送确定性图片。 - -## Consequences - -官方 DeepSeek 视觉路由与已配置视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。 diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml deleted file mode 100644 index d8a89613e9..0000000000 --- a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md -2026-08-20-canonical-image-admission.md: a30031ef72942a61865525b9ed22f97afd71e18b -2026-08-20-canonical-image-admission.zh.md: d5402a6e7bfd2d8c7de6e2a7ce611c74ec2843d2 diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md deleted file mode 100644 index a30031ef72..0000000000 --- a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.md +++ /dev/null @@ -1,28 +0,0 @@ -# Agent Note: Canonical image admission - -Status: implemented - -English | [中文](2026-08-20-canonical-image-admission.zh.md) - -## Problem - -Admission used to refuse any image above 2000px per side or 3.5 MiB, because an admitted image rides every later request and deployed routes reject oversized images. Refusal pushed the problem onto the user (downscale by hand, re-attach), and the byte size of admitted images was uncontrolled below the cap, so long sessions accumulated large request payloads. The unified image-pipeline design (PR #2676) needs a canonical, deterministic stored form as the basis for content-addressed dedup, stable request bytes, and a later provider-files upload path. - -## Decision - -`AttachmentStore.saveImage` resolves `SavedImageAttachment`: the durable `ref` describing stored bytes beside `source` facts of the submitted raster. The local store validates a wide source envelope (32 MiB, 100 MP, 16384px per side) and persists a deterministic canonical encoding: EXIF orientation baked in, metadata stripped, long edge downscaled to `canonicalMaxDimension` (default 2048px), palette PNG for alpha/PNG/GIF lineage and JPEG for photographic sources, stepping a fixed quality ladder (85/75/60/45) until `canonicalMaxBytes` (default 1 MiB) holds. An in-budget PNG/JPEG/WebP source passes through byte-identically only when it is single-frame and free of EXIF/XMP/IPTC metadata and non-default orientation, so equal originals keep one content address while location and device metadata never survive admission; GIF and every animated or metadata-carrying source re-encodes, and GIF always becomes the PNG of its first frame, pinning the first-frame meaning providers apply. Encoder parameters are fixed, not configurable — a parameter change would silently split the content-addressed space — so deployments choose only the source envelope and the canonical budget. `SourceImageInfo` records orientation-applied dimensions so source and stored raster share axes, and `validateImage` includes a canonical-encoding dry run so a validated batch can never be refused mid-write by the byte target. The canonical ref keeps the pre-existing field order (`mediaType`, `width`, `height`, `bytes`) so logged references stay byte-identical. `read_image` reports the on-disk dimensions and the coordinate multiplier whenever storage downscaled the file, naming per-axis multipliers when integer rounding makes the two ratios differ. - -## Alternatives considered - -- **Keep refusing oversized sources.** Simple, but hostile at exactly the moment a user pastes a normal screenshot from a HiDPI display, and it leaves admitted byte sizes unbounded below the cap. -- **Canonicalize at request time.** Re-encoding per request breaks byte-stable prefixes (provider context caching) and violates the design's rule that durable content is written once; the request layer only projects. -- **Make encoder quality configurable.** Two deployments with different quality would address the same source at different ids, silently defeating dedup; fixed parameters keep the space whole and an encoder upgrade re-addresses only future saves. -- **Pin a resize transcript snapshot.** A fixture embedding re-encoded bytes depends on cross-platform encoder byte-stability (libvips resize and palette quantization across arm64/x86), which is unverified in CI; the assembled snapshot instead pins the acceptance passthrough (2001x1 admitted byte-identically), and re-encode branches are pinned by package tests. - -## Verification - -Package tests cover passthrough identity, resize determinism and idempotence, GIF-to-PNG, alpha-to-PNG, JPEG ladder descent, ladder exhaustion refusal, encoder-fault mapping, and the store round-trip of a downscaled save. The read-image suite pins the downscale envelope text. The `read-image-dimension` keyless snapshot now pins the acceptance the 2000px cap used to refuse, using passthrough bytes so the fixture is platform-independent. - -## Consequences - -Ordinary large sources are admitted and bounded (≤2048px, ≤1 MiB by default), shrinking per-request image payload roughly 3.5x at the old cap and making the planned request-level budgets rarely reachable. Stored bytes may differ from the submitted file; consumers that map coordinates use the saved `source` facts, as `read_image` does. A cross-platform byte-stability check for the re-encode path remains open before any fixture may embed re-encoded bytes. diff --git a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md b/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md deleted file mode 100644 index d5402a6e7b..0000000000 --- a/.agents/notes/implemented/feature/2026-08-20-canonical-image-admission.zh.md +++ /dev/null @@ -1,28 +0,0 @@ -# Agent Note: 规范化图片准入 - -Status: implemented - -[English](2026-08-20-canonical-image-admission.md) | 中文 - -## 问题 - -准入过去拒绝任何单边超过 2000px 或超过 3.5 MiB 的图片,因为已接纳的图片会随之后每次请求发送,而已部署路由会拒绝过大的图片。拒绝把问题推给了用户(手动缩图再重新附上),而且上限以内的已接纳图片字节数不受控制,长会话会累积出很大的请求载荷。统一图片管线设计(PR #2676)需要一个规范且确定性的存储形态,作为内容寻址去重、请求字节稳定以及后续 provider files 上传路径的基础。 - -## 决定 - -`AttachmentStore.saveImage` 解析为 `SavedImageAttachment`:描述实际存储字节的持久 `ref`,加上所提交光栅的 `source` 事实。本地存储按宽松的源图上限(32 MiB、1 亿像素、单边 16384px)校验,然后持久保存确定性的规范编码:EXIF 方向落实到像素、剥离元数据、长边等比缩放到 `canonicalMaxDimension`(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定质量阶梯(85/75/60/45)递降直到满足 `canonicalMaxBytes`(默认 1 MiB)。已在预算内的 PNG/JPEG/WebP 源图只有在单帧且不携带 EXIF/XMP/IPTC 元数据、方向为默认值时才按字节原样直通,相同原图保持同一个内容地址,位置与设备元数据绝不越过准入;GIF 以及任何动图或携带元数据的源图都会重编码,GIF 一律转为首帧 PNG,在准入时固化提供方实际采用的首帧语义。编码器参数固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。`SourceImageInfo` 记录应用方向之后的尺寸,使源图与存储光栅共享坐标轴;`validateImage` 包含规范编码干跑,通过校验的批次绝不会在写入中途被字节目标拒绝。规范 ref 保持原有字段顺序(`mediaType`、`width`、`height`、`bytes`),已记录的引用保持字节一致。存储缩小了文件时,`read_image` 会报告磁盘上的原始尺寸和坐标换算倍率,取整使两轴比例不一致时分轴给出。 - -## 考虑过的替代方案 - -- **继续拒绝超限源图。** 简单,但恰恰在用户从 HiDPI 屏幕粘贴一张普通截图的时刻表现得不友好,而且上限以内的已接纳字节数仍然无界。 -- **在请求时规范化。** 按请求重编码会破坏字节稳定前缀(provider 上下文缓存),也违反设计中「持久内容只写一次、请求层只做投影」的规则。 -- **让编码质量可配置。** 两个部署用不同质量会把同一源图寻址到不同 id,悄悄破坏去重;固定参数保持寻址空间完整,编码器升级只影响之后的保存。 -- **钉一个缩放的 transcript 快照。** 嵌入重编码字节的 fixture 依赖跨平台编码器字节稳定性(libvips 缩放与调色板量化在 arm64/x86 上的表现),CI 尚未验证;组装快照改为钉住接纳直通行为(2001x1 按字节原样接纳),重编码分支由包测试钉住。 - -## 验证 - -包测试覆盖直通恒等、缩放确定性与幂等、GIF 转 PNG、透明通道转 PNG、JPEG 阶梯递降、阶梯穷尽拒绝、编码器故障映射,以及缩小保存的存储往返。read-image 测试钉住缩放信封文本。`read-image-dimension` keyless 快照现在钉住 2000px 上限过去拒绝的接纳行为,使用直通字节因此 fixture 与平台无关。 - -## 后果 - -普通大图会被接纳并受约束(默认 ≤2048px、≤1 MiB),在旧上限处把单请求图片载荷缩小约 3.5 倍,使计划中的请求级预算正常情况下难以触达。存储字节可能与提交的文件不同;需要换算坐标的消费方使用保存的 `source` 事实,`read_image` 即如此。在任何 fixture 嵌入重编码字节之前,重编码路径的跨平台字节稳定性检查仍是待办。 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 467a951f7c..53c15e1755 100644 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md -2026-08-19-direct-deepseek-vision-input.md: 76d3244e67a73c1cdf4419a6537ada38e0a75bd5 -2026-08-19-direct-deepseek-vision-input.zh.md: a77231104156371fe698f8a8ad386cfa03251990 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +2026-08-20-unified-image-request-pipeline.md: c487f583e4770b8d495404f08de67fd877dc48fd +2026-08-20-unified-image-request-pipeline.zh.md: a82312d55ba59403e71e97ee483e2b5bbfebfb03 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md new file mode 100644 index 0000000000..c487f583e4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -0,0 +1,71 @@ +# Agent Note: Unified image masters, request versions, and provider files + +Status: implemented + +English | [中文](2026-08-20-unified-image-request-pipeline.zh.md) + +## Problem + +Durable image history, provider resolution, inline request size, and remote file reuse have different limits. Treating an admitted image as the bytes sent on every later request forced one byte cap and one raster to serve all four concerns. Large but ordinary input was refused, clean 16-bit PNG could pass into history and fail at DeepSeek, repeated base64 expanded long requests, and a provider rejection repeated because the same durable image stayed in every future request. A model also had no stable way to crop a user upload that had no filesystem path. + +## Decision + +The image path has two explicit versions. The attachment backend owns a provider-independent durable master. Each image-capable model route owns a deterministic request policy, and the attachment backend derives and caches the exact request version from the master. Session history contains only the master reference; inline bytes and provider file ids remain transient request projections. + +### Provider-independent master + +Admission fully decodes each source under a configurable 32MiB, 100MP, and 16384px-per-side envelope. It applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `masterMaxDimension`, 2048px by default. `sourceWidth` and `sourceHeight` record orientation-applied dimensions when preparation reduces the raster. + +The master has an independent `masterMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both master limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. + +Batch admission prepares and verifies every master once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. + +### Deterministic request versions + +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 master projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. + +The `variantId` and cache path cover the master attachment id, transform version, route pixel and byte budgets, optional master-coordinate crop, and fixed encoder parameters. Cached output is fully decoded before reuse. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the master byte count. Equal in-process `variantId` calls share one transform and cache write; cancellation rejects only that waiter. `AttachmentStore.readImageRequests` preserves input order while the local implementation runs master and request transforms through one FIFO limiter. `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every master has been prepared. + +Request-size offload is a deterministic oldest-first projection. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. + +### Stable handles and master-coordinate crops + +Every retained request image is preceded by its complete attachment id, actual request dimensions, and the preview-coordinate arguments for `read_image_region`. The tool accepts only an attachment already referenced by the calling session. It maps the supplied preview rectangle to the 2048px master with floor-at-origin and ceil-at-far-edge rounding, crops the master rather than the preview, and persists the result as a new attachment. The tool result contains the new `ImageBlock`, so model-visible output and the durable log remain equivalent. + +### DeepSeek Files lifecycle + +The direct `deepseek-official` adapter uploads every retained request version through the OpenAI-compatible Files API and sends only `file_id` content blocks. There is no inline fallback. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by endpoint and API-key scope plus `variantId`. Uploads request seven days by default and record the returned `expires_at`; a mapping with no more than one hour remaining is replaced without a preceding retrieve call. The index never stores the API key. + +An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports an expired, deleted, missing, or invalid id and names one used id, only that mapping is removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error deletes the configured number of oldest harness-owned `dsh-` files and retries once. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. + +### Diagnostics + +A 16-bit RGB or RGBA PNG is normal admitted input and converts to 8-bit sRGB/sRGBA. If local conversion fails, `read_image` names the path, detected 16-bit PNG, required canonical form, and manual conversion remedy. If DeepSeek rejects a normalized request version, the primary error names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. An ambiguous multi-image rejection lists every candidate. The raw provider body remains the error cause rather than the only visible message. + +Historical attachment objects that later disappear or fail integrity verification remain fail-loud. Durable quarantine and verified recovery require session events and are tracked by [Quarantine unreadable historical attachments](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md). + +## Alternatives considered + +**Use one 1MiB canonical image for storage and requests.** This makes model resolution determine durable quality, reduces the source for later crops, and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent master and request policies keep those responsibilities explicit. + +**Reject images above provider dimensions or at the encoding quality floor.** A provider limit is route-specific and future requests may use another model. Proportional master preparation and request projection accept ordinary large images while bounding each later representation. + +**Treat PNG as a screenshot and reject 16-bit PNG.** File format does not reveal pixel complexity, and 16-bit RGB/RGBA is a convertible sample depth rather than an unsupported image type. Pixel sampling and post-conversion probes give the required facts. + +**Keep DeepSeek data URLs.** Inline base64 repeats bytes on every request and caps usable image history by request-body size. Files API references reuse uploaded deterministic request bytes and provide explicit expiry and deletion. + +**Trust a locally indexed file id indefinitely.** Remote expiry, deletion, and lost upload responses make local and provider state diverge. Response-directed invalidation and one re-upload recover without an unbounded retry loop; an ambiguous stale-file response must invalidate every file used by that attempt because it provides no safe exact target. + +**Crop the request preview.** Repeated crops would compound the 640,000-pixel reduction and make coordinates depend on previous encodes. Mapping back to the master preserves the available local detail. + +**Refuse text-only model selection after any image.** Durable history can outlive the model that first consumed it. Request-local placeholders keep the session usable without rewriting history. + +**Remove one image whenever a request crosses its limit.** That changes an early request message after nearly every new upload. Quantized removed prefixes keep cache invalidation occasional while honoring the configured high bound. + +## Verification + +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants, bound transform concurrency, preserve cache and upload identity, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from exact and ambiguous stale-id responses, delete quota files, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. + +## Consequences + +Durable masters consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests now depend on Files API availability; bounded stale-id recovery handles inconsistent remote state, while a general Files outage remains a visible request failure. Missing or corrupt durable masters still require the separate quarantine design. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md new file mode 100644 index 0000000000..a82312d55b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -0,0 +1,71 @@ +# Agent Note: 统一图片主版本、请求版本与提供方文件 + +Status: implemented + +[English](2026-08-20-unified-image-request-pipeline.md) | 中文 + +## Problem + +持久图片历史、提供方分辨率、内联请求大小和远端文件复用有不同限制。过去把已接纳图片直接作为之后每次请求发送的字节,导致一个字节上限和一份光栅同时承担四种职责。普通大图会被拒绝;干净的 16-bit PNG 可以进入历史,之后才被 DeepSeek 拒绝;重复 base64 使长会话请求持续增长;提供方拒绝后,同一持久图片还会进入每次后续请求。模型也无法稳定裁剪没有文件系统路径的用户上传图片。 + +## Decision + +图片路径有两个显式版本。附件后端拥有提供方无关的持久主版本。每条支持图片的模型路由拥有确定性请求策略,附件后端从主版本派生并缓存确切请求版本。会话历史只包含主版本引用;内联字节和提供方文件 ID 都是瞬时请求投影。 + +### 提供方无关的主版本 + +准入在可配置的 32MiB、1 亿像素和单边 16384px 源图范围内完整解码每张图片。处理会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`,默认 2048px。处理缩小光栅时,`sourceWidth` 和 `sourceHeight` 记录应用方向后的源尺寸。 + +主版本有独立的 `masterMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 + +批量准入在发布任何成员前,为每张图片各准备并验证一次主版本。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 + +### 确定性请求版本 + +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 主版本在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 + +`variantId` 和缓存路径覆盖主附件 ID、变换策略版本、路由像素和字节预算、可选的主版本坐标裁剪区域及固定编码参数。缓存输出会在复用前完整解码。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用主版本字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入;取消只拒绝对应等待方。`AttachmentStore.readImageRequests` 保持输入顺序,本地实现则通过一个 FIFO 限流器运行主版本和请求版本变换。`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部主版本准备完成后,批次仍按顺序发布。 + +请求大小 offload 是确定性的从旧到新投影。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 + +### 稳定句柄与主版本坐标裁剪 + +每张保留请求图片前都有完整附件 ID、实际请求尺寸和 `read_image_region` 所需的预览坐标参数。该工具只接受调用会话已经引用的附件。它按起点向下取整、远端边界向上取整,把提交的预览矩形映射到 2048px 主版本,从主版本而非预览图裁剪,并把结果保存为新附件。工具结果包含新的 `ImageBlock`,因此模型可见输出与持久日志保持一致。 + +### DeepSeek Files 生命周期 + +直接 `deepseek-official` 适配器通过 OpenAI 兼容 Files API 上传每张保留的请求版本,只发送 `file_id` 内容块,不提供内联回退。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期,并记录返回的 `expires_at`;本地映射剩余时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 + +只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的某个 ID,适配器只删除该映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会删除配置数量的最旧 `dsh-` 文件,然后重试一次。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 + +### 诊断 + +16-bit RGB 或 RGBA PNG 属于普通可接纳输入,会转换为 8-bit sRGB/sRGBA。本地转换失败时,`read_image` 会写明路径、检测到的 16-bit PNG、所需规范形式和手工转换方法。如果 DeepSeek 拒绝已规范化请求版本,主错误会写明附件 ID 或显示名称、持久消息和图片位置、规范化媒体类型、8-bit sRGB/sRGBA 位深、尺寸和提供方消息。多图片错误无法确定对象时会列出全部候选图片。原始提供方正文保留为错误 cause,不会成为唯一可见消息。 + +持久附件对象之后缺失或无法通过完整性校验时,系统仍会明确失败。持久隔离和经校验恢复需要新增会话事件,由[隔离不可读历史附件](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md)继续跟踪。 + +## Alternatives considered + +**使用一份 1MiB 规范图片同时负责存储和请求。** 这种做法让模型分辨率决定持久质量,降低之后裁剪可用的源信息,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的主版本和请求策略会明确区分这些职责。 + +**拒绝超过提供方尺寸或达到编码质量下限的图片。** 提供方限制属于具体路由,未来请求可能改用另一个模型。按比例准备主版本和投影请求版本可以接纳普通大图,同时约束每种后续表示。 + +**把 PNG 当作截图,并拒绝 16-bit PNG。** 文件格式不能说明像素复杂度,16-bit RGB/RGBA 是可转换位深,不是不支持的图片类型。像素采样和转换后探测能提供所需事实。 + +**继续向 DeepSeek 发送 data URL。** 内联 base64 会在每次请求中重复字节,并按请求正文大小限制可用图片历史。Files API 引用会复用上传后的确定性请求字节,并提供显式有效期和删除操作。 + +**永久信任本地索引中的文件 ID。** 远端过期、删除和上传响应丢失会使本地与提供方状态不一致。按响应失效和一次重新上传可以恢复,同时避免无界重试;响应没有给出可安全使用的精确目标时,必须使该次请求使用的全部文件失效。 + +**从请求预览图裁剪。** 重复裁剪会叠加 640,000 像素缩小,坐标也会依赖之前的编码。映射回主版本能保留本地可用细节。 + +**历史中出现图片后拒绝选择纯文本模型。** 持久历史可能比最初读取它的模型存活更久。按请求生成的占位文本可以保持会话可用,无需改写历史。 + +**请求每次越过上限就移除一张图片。** 这种做法会在几乎每次新增图片后改写较早的请求消息。按固定步长递增的移除前缀会降低缓存失效频率,同时遵守配置的上限。 + +## Verification + +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体 singleflight、变换并发上限、缓存与上传身份、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、精确和模糊失效响应只恢复一次、配额删除、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 + +## Consequences + +持久主版本最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求现在依赖 Files API 可用性;有界的陈旧 ID 恢复会处理远端状态不一致,一般 Files 故障仍会成为可见请求失败。缺失或损坏的持久主版本仍需要单独的隔离设计。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a17400de81..3523b633cd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: c3ae3421d52c2a6c4432b6c7784c1bae53625a24 -config-catalog.zh.md: 4e6b57a42e3269935cae8765cd0c7998c39115f4 +config-catalog.md: dd91a870ecb338e784acdd1ffa0a470fa33d8813 +config-catalog.zh.md: a412a4f0afe652863cda1edad0e344b17e1697ac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c3ae3421d5..dd91a870ec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -337,14 +337,16 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ maxImageDimension?: number - /** Long-edge pixel target of the stored canonical encoding. */ - canonicalMaxDimension?: number - /** Encoded-byte target of the stored canonical encoding. */ - canonicalMaxBytes?: number + /** Long-edge pixel cap of the stored provider-independent master version. */ + masterMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent master version. */ + masterMaxBytes?: number + /** Maximum simultaneous master or request-image transformations in this service instance. */ + imageCompressionConcurrency?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:36`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) @@ -936,8 +938,20 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Maximum accumulated base64 image payload per request (default 20 MiB). */ - maxRequestImageBytes?: number + /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ + maxRequestFilesBytes?: number + /** Maximum number of file-referenced images per chat request (default 600). */ + maxImagesPerRequest?: number + /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ + imageOffloadByteQuantum?: number + /** Image-count removal step after the request exceeds its count bound (default 20). */ + imageOffloadCountQuantum?: number + /** Explicit lifetime assigned to each uploaded image (default seven days). */ + fileExpiresAfterSeconds?: number + /** Remaining lifetime below which an indexed file is replaced (default one hour). */ + fileRefreshMarginSeconds?: number + /** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */ + fileQuotaCleanupBatch?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -956,12 +970,18 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] + /** Total-pixel budget for one deterministic request preview. */ + imagePixelBudget?: number + /** Encoded-byte cap for one deterministic request preview. */ + imageMaxBytes?: number + /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ + imageDetail?: 'auto' | 'low' } ``` Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:100`](../packages/llm/llm-deepseek/src/index.ts) @@ -1063,6 +1083,10 @@ export interface PiAiProviderProfile { * requests instead of being rejected by a request-size cap. */ maxRequestImageBytes?: number + /** Total-pixel budget for each deterministic inline request version. */ + requestImagePixelBudget?: number + /** Raw encoded-byte cap for each deterministic inline request version. */ + requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -1211,7 +1235,7 @@ export type PiAiThinkingFormat = NonNullable diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4e6b57a42e..a412a4f0af 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -339,14 +339,16 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ maxImageDimension?: number - /** Long-edge pixel target of the stored canonical encoding. */ - canonicalMaxDimension?: number - /** Encoded-byte target of the stored canonical encoding. */ - canonicalMaxBytes?: number + /** Long-edge pixel cap of the stored provider-independent master version. */ + masterMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent master version. */ + masterMaxBytes?: number + /** Maximum simultaneous master or request-image transformations in this service instance. */ + imageCompressionConcurrency?: number } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:36`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) @@ -938,8 +940,20 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Maximum accumulated base64 image payload per request (default 20 MiB). */ - maxRequestImageBytes?: number + /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ + maxRequestFilesBytes?: number + /** Maximum number of file-referenced images per chat request (default 600). */ + maxImagesPerRequest?: number + /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ + imageOffloadByteQuantum?: number + /** Image-count removal step after the request exceeds its count bound (default 20). */ + imageOffloadCountQuantum?: number + /** Explicit lifetime assigned to each uploaded image (default seven days). */ + fileExpiresAfterSeconds?: number + /** Remaining lifetime below which an indexed file is replaced (default one hour). */ + fileRefreshMarginSeconds?: number + /** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */ + fileQuotaCleanupBatch?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -958,12 +972,18 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] + /** Total-pixel budget for one deterministic request preview. */ + imagePixelBudget?: number + /** Encoded-byte cap for one deterministic request preview. */ + imageMaxBytes?: number + /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ + imageDetail?: 'auto' | 'low' } ``` 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:100`](../packages/llm/llm-deepseek/src/index.ts) @@ -1065,6 +1085,10 @@ export interface PiAiProviderProfile { * requests instead of being rejected by a request-size cap. */ maxRequestImageBytes?: number + /** Total-pixel budget for each deterministic inline request version. */ + requestImagePixelBudget?: number + /** Raw encoded-byte cap for each deterministic inline request version. */ + requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -1213,7 +1237,7 @@ export type PiAiThinkingFormat = NonNullable diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 9aef71c868..c37d706383 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: d68f92d317dec2fd05813c1ce487bb88c369bd6e -event-producer-consumer.zh.md: 5b3454e6000f4e1e017cb494423736f2c0f75f31 +event-producer-consumer.md: 1fb65d55f5a0d8121f4f171c956196fde746103f +event-producer-consumer.zh.md: d8db21e5266f83a5fc403a9b825bc05530e61b8d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d68f92d317..1fb65d55f5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -38,7 +38,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5b3454e600..d8db21e526 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -40,7 +40,7 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index f904af9a27..7c236d6480 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: 780d4744dc7ca8cada6209476cd208cf8ef95bc2 -attachment.zh.md: 843eca1c4deda9d3499207a2d0f163e401a50c9b +attachment.md: cdbb528d30eabc74a9c3607d67e91af053c45e7e +attachment.zh.md: 79ee753d22c3adecaca153659181e113f2b3e728 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 780d4744dc..cdbb528d30 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -32,6 +32,10 @@ interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string + /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ + sourceWidth?: number + /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ + sourceHeight?: number } ``` @@ -83,7 +87,65 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `admitEncodedImages()` is the wire entry for base64 uploads: it enforces canonical base64, then delegates batch admission to `saveImages()`, which owns the count and aggregate-byte limits and the validate-all-before-save order. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +```ts type-equiv +/** Pixel rectangle in the oriented 2048px master-version coordinate system. */ +interface MasterImageCrop { + x: number + y: number + width: number + height: number +} +``` + +```ts type-equiv +/** Deterministic request-image policy selected by one exact model route. */ +interface ImageRequestPolicy { + /** Maximum width multiplied by height after aspect-preserving projection. */ + maxPixels: number + /** Encoded-byte cap before base64 expansion or Files API upload. */ + maxBytes: number + /** Optional master-coordinate crop applied before pixel-budget scaling. */ + crop?: MasterImageCrop +} +``` + +```ts type-equiv +/** Crop coordinates measured by a model on the request preview it received. */ +interface PreviewImageCrop { + previewWidth: number + previewHeight: number + x: number + y: number + width: number + height: number +} +``` + +```ts type-equiv +/** Cached request version derived from one provider-independent master attachment. */ +interface RequestImageAttachment { + /** Cache and upload-index key over the master id, policy, crop, and fixed encoder parameters. */ + variantId: ImageVariantId + /** Durable master reference from which this request version was derived. */ + master: ImageAttachmentRef + /** Encoded request bytes. */ + data: Uint8Array + mediaType: ImageMediaType + bytes: number + width: number + height: number + /** Provider-compatible sample depth proven after request encoding. */ + depth: 'uchar' + /** Provider-compatible color space proven after request encoding. */ + space: 'srgb' + /** Whether the encoded request version retains an alpha channel. */ + hasAlpha: boolean + /** Applied master-coordinate crop, when present. */ + crop?: MasterImageCrop +} +``` + +`saveImage()` prepares a provider-independent 2048px, 4MiB master and atomically commits it before returning its reference. `saveImages()` prepares every validated master once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a master from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; `readImageRequests()` lets an implementation apply its configured bounded transform concurrency to an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, and defaults to two simultaneous transformations. `cropImage()` maps model preview coordinates back to the master and returns another durable attachment. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -109,18 +171,15 @@ Immutable binary attachment service. Implementations validate bytes before publi abstract validateImage(input: SaveImageAttachment): Promise /** - * Validate one ordered image batch before committing any member. - * Validation failures start no writes; storage failures return no partial - * references, although already published content-addressed objects may stay - * unreachable until a future retention policy collects them. - * @param inputs - encoded images in their owning message order. - * @returns durable references in the exact input order. + * Validate and durably commit one ordered image batch. + * @param inputs - encoded images in owning-message order. + * @returns durable master references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a canonical re-encoding of the submitted raster; + * Implementations may store a prepared master version of the submitted raster; * the returned reference always describes the stored bytes, while `source` * preserves the submitted raster's intrinsic facts for callers that report * or map coordinates against the original. @@ -133,10 +192,38 @@ abstract saveImage(input: SaveImageAttachment): Promise * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and canonical reference. + * @returns the verified bytes and master reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Generate or read one deterministic model-request version from the stored master image. + * @param ref - durable provider-independent master reference. + * @param policy - exact route pixel and encoded-byte budget. + * @param signal - optional cancellation. + * @returns request bytes and the cache/upload identity covering every transform input. + */ +async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise + +/** + * Generate or read an ordered batch of deterministic model-request versions. + * Implementations may use their own bounded transform concurrency while preserving input order. + * @param refs - durable provider-independent master references in request order. + * @param policy - exact route pixel and encoded-byte budget shared by the batch. + * @param signal - optional cancellation. + * @returns request versions in the same order as `refs`. + */ +async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise + +/** + * Crop the stored master by coordinates measured on a model request preview and persist the result. + * @param ref - session-authorized master attachment. + * @param crop - preview dimensions and preview-coordinate rectangle. + * @param signal - optional cancellation. + * @returns a new durable attachment reference suitable for a logged tool result. + */ +async cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 843eca1c4d..79ee753d22 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -32,6 +32,10 @@ interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string + /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ + sourceWidth?: number + /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ + sourceHeight?: number } ``` @@ -83,7 +87,65 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`admitEncodedImages()` 是面向 base64 上传的 wire 入口:强制执行规范 base64,随后把批量准入委托给 `saveImages()`,由后者负责张数与聚合字节上限以及先全量校验再保存的顺序。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +```ts type-equiv +/** Pixel rectangle in the oriented 2048px master-version coordinate system. */ +interface MasterImageCrop { + x: number + y: number + width: number + height: number +} +``` + +```ts type-equiv +/** Deterministic request-image policy selected by one exact model route. */ +interface ImageRequestPolicy { + /** Maximum width multiplied by height after aspect-preserving projection. */ + maxPixels: number + /** Encoded-byte cap before base64 expansion or Files API upload. */ + maxBytes: number + /** Optional master-coordinate crop applied before pixel-budget scaling. */ + crop?: MasterImageCrop +} +``` + +```ts type-equiv +/** Crop coordinates measured by a model on the request preview it received. */ +interface PreviewImageCrop { + previewWidth: number + previewHeight: number + x: number + y: number + width: number + height: number +} +``` + +```ts type-equiv +/** Cached request version derived from one provider-independent master attachment. */ +interface RequestImageAttachment { + /** Cache and upload-index key over the master id, policy, crop, and fixed encoder parameters. */ + variantId: ImageVariantId + /** Durable master reference from which this request version was derived. */ + master: ImageAttachmentRef + /** Encoded request bytes. */ + data: Uint8Array + mediaType: ImageMediaType + bytes: number + width: number + height: number + /** Provider-compatible sample depth proven after request encoding. */ + depth: 'uchar' + /** Provider-compatible color space proven after request encoding. */ + space: 'srgb' + /** Whether the encoded request version retains an alpha channel. */ + hasAlpha: boolean + /** Applied master-coordinate crop, when present. */ + crop?: MasterImageCrop +} +``` + +`saveImage()` 准备提供方无关的 2048px、4MiB 主版本,并在返回引用前以原子方式提交。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的主版本,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的主版本。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;`readImageRequests()` 允许实现按自身配置的有界变换并发处理有序批次。本地实现按需编码首选候选、合并相同请求身份的并发任务,默认同时执行两项变换。`cropImage()` 把模型预览坐标映射回主版本,并返回另一个持久附件。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -109,18 +171,15 @@ Immutable binary attachment service. Implementations validate bytes before publi abstract validateImage(input: SaveImageAttachment): Promise /** - * Validate one ordered image batch before committing any member. - * Validation failures start no writes; storage failures return no partial - * references, although already published content-addressed objects may stay - * unreachable until a future retention policy collects them. - * @param inputs - encoded images in their owning message order. - * @returns durable references in the exact input order. + * Validate and durably commit one ordered image batch. + * @param inputs - encoded images in owning-message order. + * @returns durable master references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a canonical re-encoding of the submitted raster; + * Implementations may store a prepared master version of the submitted raster; * the returned reference always describes the stored bytes, while `source` * preserves the submitted raster's intrinsic facts for callers that report * or map coordinates against the original. @@ -133,10 +192,38 @@ abstract saveImage(input: SaveImageAttachment): Promise * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and canonical reference. + * @returns the verified bytes and master reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Generate or read one deterministic model-request version from the stored master image. + * @param ref - durable provider-independent master reference. + * @param policy - exact route pixel and encoded-byte budget. + * @param signal - optional cancellation. + * @returns request bytes and the cache/upload identity covering every transform input. + */ +async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise + +/** + * Generate or read an ordered batch of deterministic model-request versions. + * Implementations may use their own bounded transform concurrency while preserving input order. + * @param refs - durable provider-independent master references in request order. + * @param policy - exact route pixel and encoded-byte budget shared by the batch. + * @param signal - optional cancellation. + * @returns request versions in the same order as `refs`. + */ +async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise + +/** + * Crop the stored master by coordinates measured on a model request preview and persist the result. + * @param ref - session-authorized master attachment. + * @param crop - preview dimensions and preview-coordinate rectangle. + * @param signal - optional cancellation. + * @returns a new durable attachment reference suitable for a logged tool result. + */ +async cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 5c118e9782..cd1adacbe3 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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 docs/subsystems/llm-streaming.md -llm-streaming.md: 4f322ca1024b9d74a4906e34f93fc6f8e4082cbf -llm-streaming.zh.md: c74cabe27c1f5fdd44711ac0aae7cd6b0a7ba7dd +llm-streaming.md: 1b2356983be4045666f7a9d40d8d191bdb4910a2 +llm-streaming.zh.md: 0c2b64830dda74595deac7797c759be1970ccb32 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4f322ca102..1b2356983b 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -675,6 +675,8 @@ interface PreparedLlmCall { readonly retryPolicy: ResolvedRetryPolicy /** Detached context metadata resolved with the registration-bound call. */ readonly context?: LlmModelContext + /** Exact model modalities captured with the adapter dispatch generation. */ + readonly inputModalities?: readonly ModelModality[] /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** @@ -730,6 +732,16 @@ declare abstract class LlmAdapter { model: string, _signal?: AbortSignal, ): Promise; + /** + * Bind exact model metadata and the eventual request dispatch to one adapter generation. + * Dynamic adapters override this so settings changes between preparation and + * dispatch cannot combine one generation's capabilities with another's endpoint. + * @param provider - registered provider route. + * @param model - exact model id. + * @param signal - cancellation for model resolution. + * @returns model metadata and a one-generation stream entry point. + */ + async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index c74cabe27c..0c2b64830d 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -681,6 +681,8 @@ interface PreparedLlmCall { readonly retryPolicy: ResolvedRetryPolicy /** Detached context metadata resolved with the registration-bound call. */ readonly context?: LlmModelContext + /** Exact model modalities captured with the adapter dispatch generation. */ + readonly inputModalities?: readonly ModelModality[] /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** @@ -736,6 +738,16 @@ declare abstract class LlmAdapter { model: string, _signal?: AbortSignal, ): Promise; + /** + * Bind exact model metadata and the eventual request dispatch to one adapter generation. + * Dynamic adapters override this so settings changes between preparation and + * dispatch cannot combine one generation's capabilities with another's endpoint. + * @param provider - registered provider route. + * @param model - exact model id. + * @param signal - cancellation for model resolution. + * @returns model metadata and a one-generation stream entry point. + */ + async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 9dfb15cdf6..d219a4c8ad 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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 docs/tool-catalog.md -tool-catalog.md: 92b6d8b92050d2dc822f016c18d31a81b43ef447 -tool-catalog.zh.md: e57eaf0a74c4a3cb5858d991a73decc694c7abc0 +tool-catalog.md: 11a7aead7938fca40d20096e3689890258fbe31c +tool-catalog.zh.md: f29d489441b36318523e0afa2eeab9104e639fd0 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 92b6d8b920..11a7aead79 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -24,7 +24,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. | -| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `read_image_region`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (image-tool registration)`, `ctx.llm + an image-capable route (image-tool execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image and read_image_region)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tools are not registered without `ctx.attachments`; their schemas are route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-terminal` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.terminals`, `ctx.systemPrompt`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot shell/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.jobs`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | @@ -714,6 +714,57 @@ Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) +### `read_image_region` + +Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image. + +```json +{ + "type": "object", + "properties": { + "attachment_id": { + "type": "string", + "description": "Complete attachment id shown beside the image." + }, + "preview_width": { + "type": "integer", + "description": "Width of the preview shown to the model." + }, + "preview_height": { + "type": "integer", + "description": "Height of the preview shown to the model." + }, + "x": { + "type": "integer", + "description": "Left edge in preview pixels." + }, + "y": { + "type": "integer", + "description": "Top edge in preview pixels." + }, + "width": { + "type": "integer", + "description": "Crop width in preview pixels." + }, + "height": { + "type": "integer", + "description": "Crop height in preview pixels." + } + }, + "required": [ + "attachment_id", + "preview_width", + "preview_height", + "x", + "y", + "width", + "height" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) + ### `write` Create or fully replace a UTF-8 text file. @@ -740,7 +791,7 @@ Create or fully replace a UTF-8 text file. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tools are not registered without `ctx.attachments`; their schemas are route-independent, and execution refuses unless the exact routed model declares image input. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index e57eaf0a74..f29d489441 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -28,7 +28,7 @@ | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | -| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | +| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`read_image_region`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (image-tool registration)`、`ctx.llm + an image-capable route (image-tool execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image and read_image_region)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | | `@deepseek-ai/dsh-tool-terminal` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.terminals`、`ctx.systemPrompt`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.jobs`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 | | `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 | @@ -720,6 +720,57 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) +### `read_image_region` + +裁剪当前会话中模型已经可见的图片附件。坐标采用该图片旁给出的预览尺寸。 + +```json +{ + "type": "object", + "properties": { + "attachment_id": { + "type": "string", + "description": "Complete attachment id shown beside the image." + }, + "preview_width": { + "type": "integer", + "description": "Width of the preview shown to the model." + }, + "preview_height": { + "type": "integer", + "description": "Height of the preview shown to the model." + }, + "x": { + "type": "integer", + "description": "Left edge in preview pixels." + }, + "y": { + "type": "integer", + "description": "Top edge in preview pixels." + }, + "width": { + "type": "integer", + "description": "Crop width in preview pixels." + }, + "height": { + "type": "integer", + "description": "Crop height in preview pixels." + } + }, + "required": [ + "attachment_id", + "preview_width", + "preview_height", + "x", + "y", + "width", + "height" + ] +} +``` + +来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) + ### `write` 创建或完全替换 UTF-8 文本文件。 @@ -746,7 +797,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 +先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index a6f12fdad5..8da7ac71b1 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -702,30 +702,63 @@ defineAcpSnapshotSuite({ hasPwsh, }) -it('pins native DeepSeek image offload in the request sent by the assembled app', async () => { +it('pins native DeepSeek Files image offload in the request sent by the assembled app', async () => { const requests: Record[] = [] + const fileRequests: Array<{ method: string; path: string; bytes: number }> = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) request.on('end', () => { - requests.push(JSON.parse(body) as Record) - response.writeHead(200, { 'content-type': 'text/event-stream' }) - const events = requests.length === 1 - ? [ - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - : [ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - response.end(events.join('\n\n')) + void (async () => { + const url = new URL(request.url ?? '/', 'http://localhost') + const body = Buffer.concat(chunks) + if (url.pathname === '/files' && request.method === 'POST') { + const headers = new Headers() + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value) + } + const form = await new Request('http://localhost/files', { + method: 'POST', headers, body, + }).formData() + const file = form.get('file') + if (!(file instanceof Blob)) throw new Error('snapshot Files upload omitted file') + fileRequests.push({ method: 'POST', path: url.pathname, bytes: file.size }) + const createdAt = Math.floor(Date.now() / 1_000) + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ + id: 'file-api-snapshot-1', + object: 'file', + bytes: file.size, + created_at: createdAt, + filename: 'dsh-snapshot.png', + purpose: 'user_data', + expires_at: createdAt + Number(form.get('expires_after[seconds]')), + })) + return + } + if (url.pathname !== '/chat/completions') { + response.writeHead(404).end() + return + } + requests.push(JSON.parse(body.toString('utf8')) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + const events = requests.length === 1 + ? [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + : [ + 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + response.end(events.join('\n\n')) + })().catch((error: unknown) => { + response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error)) + }) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -764,34 +797,22 @@ it('pins native DeepSeek image offload in the request sent by the assembled app' }) expect(result.stderr).toBe('') expect(requests).toHaveLength(2) + expect(fileRequests).toEqual([{ method: 'POST', path: '/files', bytes: 69 }]) const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) - expect(offloaded?.content).toMatchInlineSnapshot(` - [ - { - "text": "Compare the older image ", - "type": "text", - }, - { - "text": "[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]", - "type": "text", - }, - { - "text": " with the newer image ", - "type": "text", - }, - { - "image_url": { - "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", - }, - "type": "image_url", - }, - { - "text": ", then use read_image on red.png and reply with DONE.", - "type": "text", - }, - ] - `) + expect(offloaded?.content).toEqual([ + { type: 'text', text: 'Compare the older image ' }, + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: ' with the newer image ' }, + { + type: 'text', + text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' + + 'preview 1x1px. Crop coordinates use this preview. Call read_image_region with this attachment_id, ' + + 'preview_width=1, preview_height=1, x, y, width, and height.', + }, + { type: 'file', file_id: 'file-api-snapshot-1' }, + { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, + ]) const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ role?: unknown @@ -830,16 +851,16 @@ it('pins native DeepSeek image offload in the request sent by the assembled app' { role: 'tool', tool_call_id: 'native-read-image', - content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n', + content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' + + '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; preview 1x1px. ' + + 'Crop coordinates use this preview. Call read_image_region with this attachment_id, preview_width=1, ' + + 'preview_height=1, x, y, width, and height.', }, { role: 'user', content: [ { type: 'text', text: 'Attached image(s) from tool result:' }, - { - type: 'image_url', - image_url: { url: `data:image/png;base64,${image}` }, - }, + { type: 'file', file_id: 'file-api-snapshot-1' }, ], }, ]) diff --git a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml index 530f7b9663..320e66fe06 100644 --- a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml +++ b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml @@ -12,7 +12,8 @@ apiKeyEnv: DSH_SNAPSHOT_API_KEY baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled - maxRequestImageBytes: 92 + maxRequestFilesBytes: 92 + imageOffloadByteQuantum: 1 models: - id: deepseek-v4-flash-vision-exp contextWindow: 32768 diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index e3fdc4ace2..7408ddb329 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -130,6 +130,23 @@ interface ToolArgsMap { /** Path to the image file, resolved by the filesystem backend. */ file_path: string; } & Record; + /** Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image. */ + read_image_region: { + /** Complete attachment id shown beside the image. */ + attachment_id: string; + /** Width of the preview shown to the model. */ + preview_width: number; + /** Height of the preview shown to the model. */ + preview_height: number; + /** Left edge in preview pixels. */ + x: number; + /** Top edge in preview pixels. */ + y: number; + /** Crop width in preview pixels. */ + width: number; + /** Crop height in preview pixels. */ + height: number; + } & Record; /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ @@ -367,6 +384,29 @@ interface ToolOutputMap { sourceHeight?: number; }; }; + read_image_region: { + sourceAttachmentId: string; + preview: { + width: number; + height: number; + }; + crop: { + x: number; + y: number; + width: number; + height: number; + }; + image: { + attachmentId: string; + mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif"; + bytes: number; + width: number; + height: number; + name?: string; + sourceWidth?: number; + sourceHeight?: number; + }; + }; send_message: { messageId: string; }; diff --git a/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json index cce80e04c8..dec4bd85ab 100644 --- a/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json @@ -260,6 +260,52 @@ ] } }, + { + "name": "read_image_region", + "description": "Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image.", + "parameters": { + "type": "object", + "properties": { + "attachment_id": { + "type": "string", + "description": "Complete attachment id shown beside the image." + }, + "preview_width": { + "type": "integer", + "description": "Width of the preview shown to the model." + }, + "preview_height": { + "type": "integer", + "description": "Height of the preview shown to the model." + }, + "x": { + "type": "integer", + "description": "Left edge in preview pixels." + }, + "y": { + "type": "integer", + "description": "Top edge in preview pixels." + }, + "width": { + "type": "integer", + "description": "Crop width in preview pixels." + }, + "height": { + "type": "integer", + "description": "Crop height in preview pixels." + } + }, + "required": [ + "attachment_id", + "preview_width", + "preview_height", + "x", + "y", + "width", + "height" + ] + } + }, { "name": "send_message", "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 4393ddbe9d..0ebf6a80fe 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: afa38ccc125f4fb36d35bb4b94b1aea278107551 -README.zh.md: 9de7ce65447a91741810bbcd41d397a70275247b +README.md: 77b68357d5a961549bef0a015b8e48ba02fbd702 +README.zh.md: 05932c93e40d42a7f8fcdcf906f6669f6f8f7073 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index afa38ccc12..77b68357d5 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,11 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission fully decodes the raster against a wide source envelope — byte, total-pixel, and per-side caps (defaults 32MiB, 100MP, 16384px) — and then persists a deterministic canonical encoding instead of the submitted bytes: EXIF orientation is baked into pixels, metadata is stripped, the long edge is downscaled to the configured canonical target (default 2048px), sources with alpha or PNG/GIF lineage encode as palette PNG and photographic sources as JPEG, stepping down a fixed quality ladder (85/75/60/45) until the configured canonical byte target holds (default 1MiB). A PNG/JPEG/WebP source already inside the canonical budget passes through byte-identically only when it is a single frame and carries no EXIF/XMP/IPTC metadata and no non-default orientation, so equal originals keep deduplicating to one content address while location and device metadata never survive admission; GIF and every animated or metadata-carrying source re-encodes, and GIF always becomes the PNG of its first frame, pinning at admission the first-frame meaning providers apply. Encoder parameters are deliberately fixed rather than configurable, because a parameter change would silently split the content-addressed space; the deployment chooses only the source envelope and the canonical budget. An admitted image rides every later request of its session, so canonicalizing at admission is what bounds durable history without refusing ordinary large sources. `validateImage` runs the same policy including a canonical-encoding dry run, so a validated batch can never be refused mid-write by the byte target. Reads re-check the digest and logged metadata, and a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. + +Admission fully decodes the raster against a wide source envelope: 32MiB, 100MP, and 16384px per side by default. It then prepares a provider-independent master. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `masterMaxDimension` (2048px by default). The master has its own `masterMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both master limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and a converted master are each fully decoded once. `saveImages` prepares and verifies every master once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. + +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored master under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It also executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the master id, transform version, pixel and byte budgets, optional master-coordinate crop, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. `readImageRequests` schedules batches through the service's FIFO limiter. `imageCompressionConcurrency` controls simultaneous master and request transforms from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `cropImage` maps coordinates measured on a model preview back to the master, crops the master rather than the preview, and commits the crop as another durable attachment. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. @@ -12,11 +16,11 @@ Indirectly, through durable replay of historical user images and structured mode #### KV Cache effect -Canonicalization happens once at admission and is deterministic, so a stored image contributes identical request bytes on every later turn; nothing here re-encodes per request. +Master preparation and request projection are deterministic. An unchanged master and route policy reuse identical cached request bytes on later turns. ## Known Limitations and Deferred Work - Objects are retained indefinitely; reference-aware garbage collection is deferred. - The local backend assumes the host and provider adapter share this filesystem service. - Animated GIF sources keep only their first frame; animation is outside the version-one image contract. -- The canonical encoder is pinned by the installed sharp/libvips build; an encoder upgrade re-addresses future saves of the same source while already-stored objects stay valid. +- The master and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future masters or request variants while existing objects stay valid. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 9de7ce6544..05932c93e4 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,11 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入会按宽松的源图上限(字节、总像素、单边,默认 32MiB、1 亿像素、16384px)完整解码光栅图片,然后持久保存确定性的规范编码而不是提交的原始字节:EXIF 方向落实到像素并剥离元数据,长边等比缩放到配置的规范目标(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定的质量阶梯(85/75/60/45)递降,直到满足配置的规范字节目标(默认 1MiB)。已在规范预算内的 PNG/JPEG/WebP 源图只有在单帧且不携带 EXIF/XMP/IPTC 元数据、方向为默认值时才按字节原样直通,因此相同原图始终去重到同一个内容地址,而位置与设备元数据绝不会越过准入;GIF 以及任何动图或携带元数据的源图都会重编码,GIF 一律变为其首帧的 PNG,在准入时就固化提供方实际采用的首帧语义。编码器参数刻意固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。一张已接纳的图片会随会话之后的每次请求发送,所以在准入时规范化才能在不拒绝普通大图的前提下约束持久历史。`validateImage` 执行同一套策略并包含规范编码的干跑,因此通过校验的批次绝不会在写入中途被字节目标拒绝。读取会重新校验摘要和已记录的元数据,后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 + +准入针对宽松的源图范围完整解码光栅,默认上限为 32MiB、1 亿像素和单边 16384px。随后生成提供方无关的主版本:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`(默认 2048px)。主版本有独立的 `masterMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的主版本各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次主版本,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 + +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的主版本缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选仍按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含主版本 ID、变换策略版本、像素和字节预算、可选的主版本坐标裁剪区域以及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。`readImageRequests` 通过服务的 FIFO 限流器调度批次。`imageCompressionConcurrency` 控制同时执行的主版本和请求版本变换,范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。`cropImage` 把模型在预览图上测得的坐标映射回主版本,从主版本而非预览图裁剪,并把裁剪结果提交为另一个持久附件。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 @@ -12,11 +16,11 @@ #### KV 缓存影响 -规范化只在准入时发生一次且是确定性的,因此一张已存储的图片在之后每一轮贡献完全相同的请求字节;这里没有任何按请求重编码的环节。 +主版本准备和请求投影都是确定性的。主版本和路由策略不变时,之后各轮会复用相同的缓存请求字节。 ## 已知限制与待完成工作 - 对象会无限期保留;基于引用的垃圾回收尚未实现。 - 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 - 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。 -- 规范编码器由安装的 sharp/libvips 构建钉定;编码器升级会让同一源图之后的保存得到新地址,已存储对象保持有效。 +- 主版本和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的主版本或请求变体产生新地址,已有对象保持有效。 diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/canonical.ts index db4295c404..8a4193aaed 100644 --- a/packages/attachment/attachment-local/src/canonical.ts +++ b/packages/attachment/attachment-local/src/canonical.ts @@ -1,111 +1,201 @@ -/** - * Deterministic canonical image encoding. Admission stores this encoding, so - * the same source bytes always publish the same content address on one - * runtime: encoder parameters are fixed here, never configurable, because a - * parameter change would silently split the content-addressed space. The - * deployment chooses only the canonical budget (long edge and byte target). - */ +/** Deterministic provider-independent master-image encoding. */ import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' +import { detectImage } from './image.ts' import type { DetectedImage } from './image.ts' -/** Deployment-resolved canonical encoding budget. */ -export interface CanonicalImagePolicy { - /** Long-edge target in pixels; a larger source is downscaled proportionally. */ +/** Deployment-resolved storage policy for the provider-independent master version. */ +export interface MasterImagePolicy { + /** Long-edge cap in pixels; larger sources are downscaled proportionally. */ maxDimension: number - /** Encoded-byte target; a larger encoding falls down the fixed quality ladder. */ + /** Independent safety cap for encoded master bytes. */ maxBytes: number } -/** Canonical bytes beside the facts a durable reference records about them. */ -export interface CanonicalImage { +/** Master bytes beside the facts recorded by a durable reference. */ +export interface MasterImage { data: Uint8Array mediaType: ImageMediaType width: number height: number } -/** JPEG quality ladder tried in order once the preferred encoding exceeds the byte target. */ -const JPEG_QUALITIES = [85, 75, 60, 45] as const +const MASTER_QUALITIES = [85, 80, 75] as const +const LOW_COLOUR_SAMPLE_EDGE = 128 +const LOW_COLOUR_LIMIT = 256 +const MIN_SCALE_STEP = 0.9 -/** Encode one prepared pipeline and report the exact output facts. */ -async function encode(pipeline: Sharp, mediaType: 'image/png' | 'image/jpeg'): Promise { - const { data, info } = await pipeline.toBuffer({ resolveWithObject: true }) +/** Encode one prepared pipeline and report exact output facts. */ +async function encode( + pipeline: Sharp, + mediaType: 'image/png' | 'image/jpeg' | 'image/webp', + quality?: number, + palette = true, +): Promise { + const encoded = mediaType === 'image/png' + ? pipeline.png({ compressionLevel: 9, palette }) + : mediaType === 'image/webp' + ? pipeline.webp({ quality }) + : pipeline.jpeg({ quality }) + const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } } /** - * Whether stored bytes may be the submitted bytes unchanged. Byte-identical - * passthrough is preferred whenever the source already fits the budget and - * carries nothing the canonical form forbids: it keeps re-submissions of the - * same original deduplicating to the same object and never re-encodes what no - * policy requires changing. Excluded from passthrough — and therefore always - * re-encoded — are GIF and any animated container (only the first frame is - * model-visible, so admission pins that meaning instead of letting each - * provider drop frames differently) and any source carrying EXIF/XMP/IPTC - * metadata or a non-default orientation (stored objects ride every later - * request, so location and device metadata must not survive admission, and a - * stored orientation would let the recorded dimensions diverge from the - * pixels a model perceives). - * @param detected - verified source format, dimensions, and metadata facts. - * @param bytes - submitted encoded byte length. - * @param policy - resolved canonical budget. - * @returns whether the submitted encoding already is canonical. + * Whether bytes already satisfy the master-version storage contract. + * @param detected - fully decoded source facts. + * @param bytes - encoded source length. + * @param policy - resolved master limits. + * @returns whether the source can pass through byte-identically. */ -export function isCanonical(detected: DetectedImage, bytes: number, policy: CanonicalImagePolicy): boolean { +export function isMasterImage(detected: DetectedImage, bytes: number, policy: MasterImagePolicy): boolean { return detected.mediaType !== 'image/gif' && !detected.animated && !detected.carriesMetadata + && detected.depth === 'uchar' + && detected.space === 'srgb' && bytes <= policy.maxBytes && Math.max(detected.width, detected.height) <= policy.maxDimension } /** - * Produce the canonical encoding of one fully validated source raster. - * Passthrough returns the submitted array; every re-encode bakes EXIF - * orientation into pixels, strips metadata, downscales to the policy's long - * edge, and encodes with fixed parameters: PNG (palette) for sources that - * carry alpha or were PNG/GIF, JPEG for photographic sources, falling down - * one fixed JPEG quality ladder until the byte target holds. - * @param data - submitted encoded bytes, already fully decoded by admission. - * @param detected - verified source format and dimensions. - * @param policy - resolved canonical budget. - * @returns canonical bytes and their reference facts. - * @throws AttachmentError `IMAGE_TOO_LARGE` when the smallest ladder step still exceeds the byte target. + * Classify a bounded pixel sample without assuming that a PNG source is a screenshot. + * @param pipeline - oriented sRGB source pipeline before output resizing. + * @returns whether the nearest-neighbour sample stays within the low-color threshold. */ -export async function canonicalizeImage( +export async function hasLowColourCount(pipeline: Sharp): Promise { + const { data, info } = await pipeline.clone().resize({ + width: LOW_COLOUR_SAMPLE_EDGE, + height: LOW_COLOUR_SAMPLE_EDGE, + fit: 'inside', + withoutEnlargement: true, + kernel: sharp.kernel.nearest, + fastShrinkOnLoad: false, + }).raw().toBuffer({ resolveWithObject: true }) + const colours = new Set() + for (let offset = 0; offset < data.length; offset += info.channels) { + const red = data[offset] ?? 0 + const green = data[offset + 1] ?? red + const blue = data[offset + 2] ?? red + const alpha = info.channels === 2 + ? data[offset + 1] ?? 255 + : info.channels === 4 ? data[offset + 3] ?? 255 : 255 + colours.add(((red >> 3) << 15) | ((green >> 3) << 10) | ((blue >> 3) << 5) | (alpha >> 3)) + if (colours.size > LOW_COLOUR_LIMIT) return false + } + return true +} + +/** Assert that a re-encoded master is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ +async function verifyMaster(image: MasterImage, expectedAlpha: boolean | undefined): Promise { + const detected = await detectImage(image.data) + if (detected.mediaType !== image.mediaType + || detected.width !== image.width + || detected.height !== image.height + || detected.animated + || detected.carriesMetadata + || detected.depth !== 'uchar' + || detected.space !== 'srgb' + || (expectedAlpha !== undefined && detected.hasAlpha !== expectedAlpha)) { + throw new AttachmentError( + 'Canonical image conversion did not produce a single-frame 8-bit sRGB image with matching metadata.', + 'ATTACHMENT_WRITE_FAILED', + ) + } + return image +} + +/** Build one fixed-size, oriented, metadata-free sRGB pipeline from submitted bytes. */ +function preparedPipeline(data: Uint8Array, width: number, height: number): Sharp { + return sharp(data, { failOn: 'error', limitInputPixels: false }) + .rotate() + .toColourspace('srgb') + .resize({ width, height, fit: 'inside', withoutEnlargement: true }) +} + +/** Dimensions after the long edge is capped without changing aspect ratio. */ +function initialDimensions(detected: DetectedImage, maxDimension: number): { width: number; height: number } { + const scale = Math.min(1, maxDimension / Math.max(detected.width, detected.height)) + return { + width: Math.max(1, Math.round(detected.width * scale)), + height: Math.max(1, Math.round(detected.height * scale)), + } +} + +/** Lazy encoding order for one size, separated by sampled colour complexity and alpha. */ +function encodingAttemptsAtSize( + data: Uint8Array, + width: number, + height: number, + hasAlpha: boolean, + lowColour: boolean, +): Array<() => Promise> { + const prepared = preparedPipeline(data, width, height) + const webp = MASTER_QUALITIES.map(quality => ( + () => encode(prepared.clone(), 'image/webp', quality) + )) + if (lowColour) { + return [() => encode(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] + } + if (hasAlpha) return webp + return MASTER_QUALITIES.map(quality => ( + () => encode(prepared.clone(), 'image/jpeg', quality) + )) +} + +/** + * Produce the 2048px provider-independent master version of one fully decoded source. + * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, + * and inside both master limits. Re-encoding never removes transparency. After the fixed + * quality floor is reached, dimensions continue shrinking until the independent byte cap holds. + * @param data - complete admitted source bytes. + * @param detected - fully decoded source facts. + * @param policy - resolved independent master limits. + * @returns verified provider-independent master bytes and metadata. + */ +export async function prepareMasterImage( data: Uint8Array, detected: DetectedImage, - policy: CanonicalImagePolicy, -): Promise { - if (isCanonical(detected, data.byteLength, policy)) { + policy: MasterImagePolicy, +): Promise { + if (isMasterImage(detected, data.byteLength, policy)) { return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } } try { - const source = sharp(data, { failOn: 'error', limitInputPixels: false }) - const { hasAlpha } = await source.metadata() - const prepared = source.rotate().resize({ - width: policy.maxDimension, - height: policy.maxDimension, - fit: 'inside', - withoutEnlargement: true, - }) - const preferPng = hasAlpha || detected.mediaType === 'image/png' || detected.mediaType === 'image/gif' - if (preferPng) { - const png = await encode(prepared.clone().png({ compressionLevel: 9, palette: true }), 'image/png') - if (png.data.byteLength <= policy.maxBytes) return png - } - for (const quality of JPEG_QUALITIES) { - const jpeg = await encode( - prepared.clone().flatten({ background: '#ffffff' }).jpeg({ quality }), - 'image/jpeg', + let { width, height } = initialDimensions(detected, policy.maxDimension) + const classificationPipeline = sharp(data, { failOn: 'error', limitInputPixels: false }) + .rotate() + .toColourspace('srgb') + const lowColour = await hasLowColourCount(classificationPipeline) + for (;;) { + const encoded = await encodeFirstWithinLimit( + encodingAttemptsAtSize(data, width, height, detected.hasAlpha, lowColour), + policy.maxBytes, ) - if (jpeg.data.byteLength <= policy.maxBytes) return jpeg + if (!isExhaustedEncoding(encoded)) { + return await verifyMaster(encoded, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) + } + if (width === 1 && height === 1) break + const sizeScale = Math.sqrt(policy.maxBytes / encoded.smallest.data.byteLength) * 0.95 + const scale = Math.min(MIN_SCALE_STEP, sizeScale) + const nextWidth = Math.max(1, Math.floor(width * scale)) + const nextHeight = Math.max(1, Math.floor(height * scale)) + width = nextWidth === width && width > 1 ? width - 1 : nextWidth + height = nextHeight === height && height > 1 ? height - 1 : nextHeight } } catch (error) { - throw new AttachmentError('Unable to canonicalize image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + if (error instanceof AttachmentError) throw error + const source = detected.mediaType === 'image/png' && detected.depth !== 'uchar' + ? `${detected.depth === 'ushort' ? '16-bit' : detected.depth} PNG` + : `${detected.depth} ${detected.mediaType.slice('image/'.length).toUpperCase()}` + throw new AttachmentError( + `The ${source} could not be converted to the canonical 8-bit sRGB form.`, + 'ATTACHMENT_WRITE_FAILED', + { cause: error }, + ) } - throw new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE') + throw new AttachmentError('Image cannot be encoded within the configured master-image byte cap.', 'IMAGE_TOO_LARGE') } diff --git a/packages/attachment/attachment-local/src/compression-limiter.ts b/packages/attachment/attachment-local/src/compression-limiter.ts new file mode 100644 index 0000000000..3935f262a1 --- /dev/null +++ b/packages/attachment/attachment-local/src/compression-limiter.ts @@ -0,0 +1,43 @@ +/** Instance-owned concurrency bound for native image transformations. */ + +/** FIFO limiter for asynchronous compression work. */ +export class CompressionLimiter { + private active = 0 + private readonly waiting: Array<() => void> = [] + + /** + * @param concurrency - positive maximum number of active tasks. + */ + constructor(readonly concurrency: number) {} + + /** + * Run one task after an instance slot becomes available. + * @param task - compression operation occupying one slot until settlement. + * @returns the task result. + */ + run(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const start = (): void => { + this.active += 1 + const release = (): void => { + this.active -= 1 + this.waiting.shift()?.() + } + void Promise.resolve().then(task).then( + (value) => { + release() + resolve(value) + }, + (error: unknown) => { + release() + reject(error instanceof Error + ? error + : new Error('Image compression task rejected with a non-Error value.', { cause: error })) + }, + ) + } + if (this.active < this.concurrency) start() + else this.waiting.push(start) + }) + } +} diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts new file mode 100644 index 0000000000..8099046c95 --- /dev/null +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -0,0 +1,45 @@ +/** Shared lazy candidate execution for master and request-image encoders. */ + +/** One encoded candidate carrying its complete bytes. */ +export interface EncodedCandidate { + data: Uint8Array +} + +/** Result of exhausting candidates at one raster size without a fitting output. */ +export interface ExhaustedEncoding { + smallest: T +} + +/** + * Execute encoding candidates in preference order and stop after the first fitting output. + * @param attempts - lazy encoders ordered from preferred to fallback representation. + * @param maxBytes - positive encoded-byte cap. + * @returns the first fitting candidate, otherwise the smallest completed fallback. + */ +export async function encodeFirstWithinLimit( + attempts: readonly (() => Promise)[], + maxBytes: number, +): Promise> { + if (attempts.length === 0) throw new Error('image encoding requires at least one candidate') + let smallest: T | undefined + for (const attempt of attempts) { + const candidate = await attempt() + if (candidate.data.byteLength <= maxBytes) return candidate + if (smallest === undefined || candidate.data.byteLength < smallest.data.byteLength) { + smallest = candidate + } + } + if (smallest === undefined) throw new Error('image encoding did not execute a candidate') + return { smallest } +} + +/** + * Whether a lazy encoding result exhausted every candidate at one size. + * @param result - first fitting candidate or exhausted result. + * @returns whether every candidate exceeded the byte cap. + */ +export function isExhaustedEncoding( + result: T | ExhaustedEncoding, +): result is ExhaustedEncoding { + return 'smallest' in result +} diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index 991e5dc051..beedd3b8c0 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -13,8 +13,14 @@ export interface DetectedImage { height: number /** Whether the container carries more than one frame. */ animated: boolean - /** Whether the bytes carry EXIF/XMP/IPTC metadata or a non-default orientation. */ + /** Whether the bytes carry descriptive metadata, a color profile, or orientation. */ carriesMetadata: boolean + /** Sharp sample depth reported for the decoded channels. */ + depth: string + /** Sharp colour space reported for the decoded pixels. */ + space: string + /** Whether decoded pixels carry an alpha channel. */ + hasAlpha: boolean } const MEDIA_TYPES: Readonly> = { @@ -24,6 +30,17 @@ const MEDIA_TYPES: Readonly> = { gif: 'image/gif', } +function carriesRetainedMetadata(metadata: Awaited>): boolean { + return metadata.exif !== undefined + || metadata.xmp !== undefined + || metadata.iptc !== undefined + || metadata.icc !== undefined + || metadata.hasProfile + || metadata.tifftagPhotoshop !== undefined + || metadata.comments !== undefined + || metadata.orientation !== undefined +} + async function imageMetadata(image: Sharp): Promise { const metadata = await image.metadata() const mediaType = MEDIA_TYPES[metadata.format as string] @@ -38,9 +55,10 @@ async function imageMetadata(image: Sharp): Promise { width: transposed ? metadata.height : metadata.width, height: transposed ? metadata.width : metadata.height, animated: (metadata.pages ?? 1) > 1, - // orientation is EXIF-derived for every whitelisted format, so exif - // presence already covers a non-default orientation. - carriesMetadata: metadata.exif !== undefined || metadata.xmp !== undefined || metadata.iptc !== undefined, + carriesMetadata: carriesRetainedMetadata(metadata), + depth: metadata.depth, + space: metadata.space, + hasAlpha: metadata.hasAlpha, } } diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index cbe535ae7d..e43153247a 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -4,14 +4,27 @@ import { join, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + ImageRequestPolicy, + PreviewImageCrop, + RequestImageAttachment, + SaveImageAttachment, + SavedImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import type { CanonicalImagePolicy } from './canonical.ts' -import { readImageFile, saveImageFile, validateImageFile } from './store.ts' +import type { MasterImagePolicy } from './canonical.ts' +import { CompressionLimiter } from './compression-limiter.ts' +import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { previewCropToMaster, readRequestImageFile, requestImageVariantId } from './request-image.ts' -export { canonicalizeImage, isCanonical } from './canonical.ts' -export type { CanonicalImage, CanonicalImagePolicy } from './canonical.ts' -export { readImageFile, saveImageFile, validateImageFile } from './store.ts' +export { isMasterImage, prepareMasterImage } from './canonical.ts' +export type { MasterImage, MasterImagePolicy } from './canonical.ts' +export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts' +export type { PreparedImageFile } from './store.ts' +export { previewCropToMaster, readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts' /** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ export const DEFAULT_MAX_IMAGE_BYTES = 32 * 1024 * 1024 @@ -24,13 +37,17 @@ export const DEFAULT_MAX_IMAGE_PIXELS = 100_000_000 /** Default per-side pixel cap for one submitted image. */ export const DEFAULT_MAX_IMAGE_DIMENSION = 16384 /** - * Default long-edge target of the stored canonical encoding. A larger source + * Default long-edge target of the stored image master. A larger source * is admitted and downscaled to this edge, so admission bounds what rides * every later model request without refusing ordinary large sources. */ -export const DEFAULT_CANONICAL_MAX_DIMENSION = 2048 -/** Default byte target of the stored canonical encoding. */ -export const DEFAULT_CANONICAL_MAX_BYTES = 1024 * 1024 +export const DEFAULT_MASTER_MAX_DIMENSION = 2048 +/** Default independent safety cap for one stored master version. */ +export const DEFAULT_MASTER_MAX_BYTES = 4 * 1024 * 1024 +/** Conservative default number of simultaneous native image transformations per store. */ +export const DEFAULT_IMAGE_COMPRESSION_CONCURRENCY = 2 +/** Maximum configurable native image transformations per store. */ +export const MAX_IMAGE_COMPRESSION_CONCURRENCY = 8 /** Local attachment backend configuration. */ export interface Config { @@ -46,10 +63,29 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ maxImageDimension?: number - /** Long-edge pixel target of the stored canonical encoding. */ - canonicalMaxDimension?: number - /** Encoded-byte target of the stored canonical encoding. */ - canonicalMaxBytes?: number + /** Long-edge pixel cap of the stored provider-independent master version. */ + masterMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent master version. */ + masterMaxBytes?: number + /** Maximum simultaneous master or request-image transformations in this service instance. */ + imageCompressionConcurrency?: number +} + +function waitForShared(operation: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return operation + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const abort = (): void => { + const reason: unknown = signal.reason + reject(reason instanceof Error + ? reason + : new Error('Attachment request cancelled with a non-Error reason.', { cause: reason })) + } + signal.addEventListener('abort', abort, { once: true }) + void operation.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', abort) + }) + }) } /** Persistent content-addressed local attachment store. */ @@ -61,15 +97,21 @@ export class LocalAttachmentStore extends AttachmentStore { maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), - canonicalMaxDimension: z.number().step(1).min(1).default(DEFAULT_CANONICAL_MAX_DIMENSION), - canonicalMaxBytes: z.number().step(1).min(1).default(DEFAULT_CANONICAL_MAX_BYTES), + masterMaxDimension: z.number().step(1).min(1).default(DEFAULT_MASTER_MAX_DIMENSION), + masterMaxBytes: z.number().step(1).min(1).default(DEFAULT_MASTER_MAX_BYTES), + imageCompressionConcurrency: z.number().step(1).min(1).max(MAX_IMAGE_COMPRESSION_CONCURRENCY) + .default(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY), }) /** Absolute versioned storage root. */ readonly root: string readonly imageLimits: ImageAttachmentLimits - /** Resolved canonical encoding budget applied by every save. */ - readonly canonicalPolicy: Readonly + /** Resolved provider-independent master-version storage policy. */ + readonly masterPolicy: Readonly + /** Resolved instance-level compression limit. */ + readonly imageCompressionConcurrency: number + private readonly compression: CompressionLimiter + private readonly requestInflight = new Map>() constructor(ctx: Context, config: Config) { super(ctx) @@ -82,23 +124,107 @@ export class LocalAttachmentStore extends AttachmentStore { maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) - this.canonicalPolicy = Object.freeze({ - maxDimension: config.canonicalMaxDimension ?? DEFAULT_CANONICAL_MAX_DIMENSION, - maxBytes: config.canonicalMaxBytes ?? DEFAULT_CANONICAL_MAX_BYTES, + this.masterPolicy = Object.freeze({ + maxDimension: config.masterMaxDimension ?? DEFAULT_MASTER_MAX_DIMENSION, + maxBytes: config.masterMaxBytes ?? DEFAULT_MASTER_MAX_BYTES, }) + const compressionConcurrency = config.imageCompressionConcurrency ?? DEFAULT_IMAGE_COMPRESSION_CONCURRENCY + if (!Number.isSafeInteger(compressionConcurrency) + || compressionConcurrency < 1 + || compressionConcurrency > MAX_IMAGE_COMPRESSION_CONCURRENCY) { + throw new Error( + `attachment-local: imageCompressionConcurrency must be an integer from 1 through ${MAX_IMAGE_COMPRESSION_CONCURRENCY}`, + ) + } + this.imageCompressionConcurrency = compressionConcurrency + this.compression = new CompressionLimiter(compressionConcurrency) } async validateImage(input: SaveImageAttachment): Promise { - await validateImageFile(input, this.imageLimits, this.canonicalPolicy) + await this.compression.run(() => validateImageFile(input, this.imageLimits, this.masterPolicy)) + } + + override async saveImages(inputs: readonly SaveImageAttachment[]): Promise { + this.validateImageBatch(inputs) + const prepared = await Promise.all(inputs.map(input => this.compression.run( + () => prepareImageFile(input, this.imageLimits, this.masterPolicy), + ))) + const refs: ImageAttachmentRef[] = [] + for (const image of prepared) refs.push((await commitPreparedImageFile(this.root, image)).ref) + return refs } async saveImage(input: SaveImageAttachment): Promise { - return saveImageFile(this.root, input, this.imageLimits, this.canonicalPolicy) + const prepared = await this.compression.run( + () => prepareImageFile(input, this.imageLimits, this.masterPolicy), + ) + return commitPreparedImageFile(this.root, prepared) } async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise { return readImageFile(this.root, ref, signal) } + + override async readImageRequest( + ref: ImageAttachmentRef, + policy: ImageRequestPolicy, + signal?: AbortSignal, + ): Promise { + return this.requestVersion(ref, policy, undefined, signal) + } + + override async readImageRequests( + refs: readonly ImageAttachmentRef[], + policy: ImageRequestPolicy, + signal?: AbortSignal, + ): Promise { + return Promise.all(refs.map(ref => this.requestVersion(ref, policy, undefined, signal))) + } + + private requestVersion( + ref: ImageAttachmentRef, + policy: ImageRequestPolicy, + master: StoredImageAttachment | undefined, + signal: AbortSignal | undefined, + ): Promise { + signal?.throwIfAborted() + const variantId = requestImageVariantId(ref, policy) + const key = String(variantId) + let operation = this.requestInflight.get(key) + if (operation === undefined) { + operation = this.compression.run(async () => readRequestImageFile( + this.root, + master ?? await this.readImage(ref), + policy, + )) + this.requestInflight.set(key, operation) + void operation.finally(() => { + if (this.requestInflight.get(key) === operation) this.requestInflight.delete(key) + }).catch(() => {}) + } + return waitForShared(operation, signal) + } + + override async cropImage( + ref: ImageAttachmentRef, + crop: PreviewImageCrop, + signal?: AbortSignal, + ): Promise { + const master = await this.readImage(ref, signal) + const region = previewCropToMaster(ref.width, ref.height, crop) + const version = await this.requestVersion(ref, { + maxPixels: region.width * region.height, + maxBytes: this.masterPolicy.maxBytes, + crop: region, + }, master, signal) + signal?.throwIfAborted() + const stem = ref.name?.replace(/\.[^.]+$/u, '') ?? String(ref.attachmentId).slice(0, 15) + return this.saveImage({ + data: version.data, + mediaType: version.mediaType, + name: `${stem}-crop.${version.mediaType.slice('image/'.length).replace('jpeg', 'jpg')}`, + }) + } } export default LocalAttachmentStore diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts new file mode 100644 index 0000000000..9c92d78181 --- /dev/null +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -0,0 +1,353 @@ +/** Deterministic cached image versions for model requests and region reads. */ + +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import sharp, { type Sharp } from 'sharp' +import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { + ImageMediaType, + ImageAttachmentRef, + ImageRequestPolicy, + MasterImageCrop, + PreviewImageCrop, + RequestImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import { hasLowColourCount } from './canonical.ts' +import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' +import { detectImage, probeImage } from './image.ts' + +/** Transform version included in every cache and upload-index identity. */ +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v2' +/** DeepSeek request versions normally fit at these two preferred qualities. */ +export const REQUEST_IMAGE_QUALITIES = [85, 80] as const + +interface EncodedRequestImage { + data: Uint8Array + mediaType: ImageMediaType + width: number + height: number +} + +interface VerifiedRequestImage extends EncodedRequestImage { + hasAlpha: boolean +} + +function digest(value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex') +} + +/** + * Compute aspect-preserving integer dimensions within a hard total-pixel budget. + * @param width - positive source width. + * @param height - positive source height. + * @param maxPixels - positive width-times-height cap. + * @returns inward-rounded dimensions; small images are not enlarged. + */ +export function requestImageDimensions( + width: number, + height: number, + maxPixels: number, +): { width: number; height: number } { + const scale = Math.min(1, Math.sqrt(maxPixels / (width * height))) + if (scale === 1) return { width, height } + if (width >= height) { + let projectedWidth = Math.max(1, Math.floor(width * scale)) + let projectedHeight = Math.max(1, Math.round(projectedWidth * height / width)) + while (projectedWidth * projectedHeight > maxPixels && projectedWidth > 1) { + projectedWidth -= 1 + projectedHeight = Math.max(1, Math.round(projectedWidth * height / width)) + } + return { width: projectedWidth, height: projectedHeight } + } + let projectedHeight = Math.max(1, Math.floor(height * scale)) + let projectedWidth = Math.max(1, Math.round(projectedHeight * width / height)) + while (projectedWidth * projectedHeight > maxPixels && projectedHeight > 1) { + projectedHeight -= 1 + projectedWidth = Math.max(1, Math.round(projectedHeight * width / height)) + } + return { width: projectedWidth, height: projectedHeight } +} + +function checkedInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new AttachmentError(`${name} must be a positive integer.`, 'INVALID_ATTACHMENT_REF') + } + return value +} + +function validatePolicy(policy: ImageRequestPolicy): void { + checkedInteger(policy.maxPixels, 'Image request maxPixels') + checkedInteger(policy.maxBytes, 'Image request maxBytes') + if (policy.crop !== undefined) { + if (!Number.isSafeInteger(policy.crop.x) || policy.crop.x < 0 + || !Number.isSafeInteger(policy.crop.y) || policy.crop.y < 0) { + throw new AttachmentError('Image crop origin must use non-negative integer pixels.', 'INVALID_ATTACHMENT_REF') + } + checkedInteger(policy.crop.width, 'Image crop width') + checkedInteger(policy.crop.height, 'Image crop height') + } +} + +function checkedCrop(master: StoredImageAttachment, crop: MasterImageCrop | undefined): MasterImageCrop | undefined { + if (crop === undefined) return undefined + if (crop.x + crop.width > master.ref.width || crop.y + crop.height > master.ref.height) { + throw new AttachmentError('Image crop extends beyond the stored master image.', 'INVALID_ATTACHMENT_REF') + } + return crop +} + +function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): string { + return JSON.stringify({ + transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION, + masterAttachmentId: master.attachmentId, + routePixelBudget: policy.maxPixels, + encodedByteBudget: policy.maxBytes, + crop: policy.crop ?? null, + encoding: { + png: { compressionLevel: 9, palette: 'opaque-only' }, + webpQualities: REQUEST_IMAGE_QUALITIES, + jpegQualities: REQUEST_IMAGE_QUALITIES, + order: ['low-colour:png-webp', 'alpha:webp', 'opaque:jpeg'], + colourspace: 'srgb', + }, + }) +} + +/** + * Complete deterministic identity for one master and route-owned request policy. + * @param master - provider-independent durable master reference. + * @param policy - route-owned pixel, byte, and crop policy. + * @returns branded digest over every request transform input. + */ +export function requestImageVariantId( + master: ImageAttachmentRef, + policy: ImageRequestPolicy, +): ReturnType { + return ImageVariantId(`sha256:${digest(descriptor(master, policy))}`) +} + +function pipeline(master: StoredImageAttachment, crop: MasterImageCrop | undefined, width: number, height: number): Sharp { + return sourcePipeline(master, crop) + .resize({ width, height, fit: 'inside', withoutEnlargement: true }) +} + +function sourcePipeline(master: StoredImageAttachment, crop: MasterImageCrop | undefined): Sharp { + let image = sharp(master.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') + if (crop !== undefined) image = image.extract({ + left: crop.x, + top: crop.y, + width: crop.width, + height: crop.height, + }) + return image +} + +async function encoded( + image: Sharp, + mediaType: 'image/png' | 'image/jpeg' | 'image/webp', + quality?: number, + palette = true, +): Promise { + const output = mediaType === 'image/png' + ? image.png({ compressionLevel: 9, palette }) + : mediaType === 'image/webp' + ? image.webp({ quality }) + : image.jpeg({ quality }) + const { data, info } = await output.toBuffer({ resolveWithObject: true }) + return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } +} + +function encodingAttempts( + master: StoredImageAttachment, + crop: MasterImageCrop | undefined, + width: number, + height: number, + hasAlpha: boolean, + lowColour: boolean, +): Array<() => Promise> { + const prepared = pipeline(master, crop, width, height) + const webp = REQUEST_IMAGE_QUALITIES.map(quality => ( + () => encoded(prepared.clone(), 'image/webp', quality) + )) + if (lowColour) return [() => encoded(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] + if (hasAlpha) return webp + return REQUEST_IMAGE_QUALITIES.map(quality => ( + () => encoded(prepared.clone(), 'image/jpeg', quality) + )) +} + +async function createRequestImage( + master: StoredImageAttachment, + policy: ImageRequestPolicy, + hasAlpha: boolean, +): Promise { + const crop = checkedCrop(master, policy.crop) + const sourceWidth = crop?.width ?? master.ref.width + const sourceHeight = crop?.height ?? master.ref.height + let dimensions = requestImageDimensions(sourceWidth, sourceHeight, policy.maxPixels) + if (crop === undefined + && dimensions.width === master.ref.width + && dimensions.height === master.ref.height + && master.data.byteLength <= policy.maxBytes) { + return { + data: master.data, + mediaType: master.ref.mediaType, + width: master.ref.width, + height: master.ref.height, + } + } + const lowColour = await hasLowColourCount(sourcePipeline(master, crop)) + for (;;) { + const encodedVersion = await encodeFirstWithinLimit( + encodingAttempts(master, crop, dimensions.width, dimensions.height, hasAlpha, lowColour), + policy.maxBytes, + ) + if (!isExhaustedEncoding(encodedVersion)) return encodedVersion + if (dimensions.width === 1 && dimensions.height === 1) break + const scale = Math.min(0.9, Math.sqrt(policy.maxBytes / encodedVersion.smallest.data.byteLength) * 0.95) + dimensions = { + width: Math.max(1, Math.floor(dimensions.width * scale)), + height: Math.max(1, Math.floor(dimensions.height * scale)), + } + } + throw new AttachmentError('Image cannot be encoded within the model-request byte budget.', 'IMAGE_TOO_LARGE') +} + +function cachePath(root: string, hash: string): string { + return join(root, 'request-images', hash.slice(0, 2), hash) +} + +async function readCached( + path: string, + master: StoredImageAttachment, + policy: ImageRequestPolicy, + expectedAlpha: boolean, + signal?: AbortSignal, +): Promise { + try { + const data = new Uint8Array(await readFile(path, { signal })) + const detected = await detectImage(data) + const crop = policy.crop + const maximum = requestImageDimensions(crop?.width ?? master.ref.width, crop?.height ?? master.ref.height, policy.maxPixels) + if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' + || detected.width > maximum.width || detected.height > maximum.height + || detected.hasAlpha !== expectedAlpha) return undefined + return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined + signal?.throwIfAborted() + return undefined + } +} + +async function verifyRequestImage( + image: EncodedRequestImage, + expectedAlpha: boolean, +): Promise { + const detected = await detectImage(image.data) + if (detected.depth !== 'uchar' || detected.space !== 'srgb' + || detected.width !== image.width || detected.height !== image.height + || detected.mediaType !== image.mediaType || detected.hasAlpha !== expectedAlpha) { + throw new AttachmentError( + 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', + 'ATTACHMENT_WRITE_FAILED', + ) + } + return { ...image, hasAlpha: detected.hasAlpha } +} + +async function writeCached(path: string, data: Uint8Array): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + const temporary = `${path}.${randomUUID()}.tmp` + try { + await writeFile(temporary, data, { mode: 0o600, flag: 'wx' }) + try { + await rename(temporary, path) + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'EEXIST') throw error + } + } finally { + await rm(temporary, { force: true }) + } +} + +/** + * Generate or reuse one request image below the local attachment root. + * @param root - absolute versioned attachment storage root. + * @param master - verified stored master bytes and reference. + * @param policy - exact route request-image policy. + * @param signal - optional cancellation for cache I/O. + * @returns verified request bytes and deterministic variant identity. + */ +export async function readRequestImageFile( + root: string, + master: StoredImageAttachment, + policy: ImageRequestPolicy, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + validatePolicy(policy) + checkedCrop(master, policy.crop) + const source = await probeImage(master.data) + const variantId = requestImageVariantId(master.ref, policy) + const hash = String(variantId).slice('sha256:'.length) + const path = cachePath(root, hash) + const cached = await readCached(path, master, policy, source.hasAlpha, signal) + const created = cached ?? await createRequestImage(master, policy, source.hasAlpha) + const version = cached ?? (created.data === master.data + ? { ...created, hasAlpha: source.hasAlpha } + : await verifyRequestImage(created, source.hasAlpha)) + signal?.throwIfAborted() + if (cached === undefined && version.data !== master.data) await writeCached(path, version.data) + return { + variantId, + master: master.ref, + data: version.data, + mediaType: version.mediaType, + bytes: version.data.byteLength, + width: version.width, + height: version.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: version.hasAlpha, + ...policy.crop === undefined ? {} : { crop: policy.crop }, + } +} + +/** + * Map a preview-coordinate rectangle to the oriented stored master. + * @param masterWidth - stored master width. + * @param masterHeight - stored master height. + * @param crop - rectangle measured on the model-visible preview. + * @returns covering integer rectangle in master coordinates. + */ +export function previewCropToMaster( + masterWidth: number, + masterHeight: number, + crop: PreviewImageCrop, +): MasterImageCrop { + checkedInteger(masterWidth, 'Master image width') + checkedInteger(masterHeight, 'Master image height') + checkedInteger(crop.previewWidth, 'Preview width') + checkedInteger(crop.previewHeight, 'Preview height') + if (!Number.isSafeInteger(crop.x) || crop.x < 0 || !Number.isSafeInteger(crop.y) || crop.y < 0) { + throw new AttachmentError('Preview crop origin must use non-negative integer pixels.', 'INVALID_ATTACHMENT_REF') + } + checkedInteger(crop.width, 'Preview crop width') + checkedInteger(crop.height, 'Preview crop height') + if (crop.x + crop.width > crop.previewWidth || crop.y + crop.height > crop.previewHeight) { + throw new AttachmentError('Preview crop extends beyond the image shown to the model.', 'INVALID_ATTACHMENT_REF') + } + const x = Math.floor(crop.x * masterWidth / crop.previewWidth) + const y = Math.floor(crop.y * masterHeight / crop.previewHeight) + const right = Math.ceil((crop.x + crop.width) * masterWidth / crop.previewWidth) + const bottom = Math.ceil((crop.y + crop.height) * masterHeight / crop.previewHeight) + return { + x, + y, + width: Math.max(1, Math.min(masterWidth, right) - x), + height: Math.max(1, Math.min(masterHeight, bottom) - y), + } +} diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 9964c2a94f..ba45256416 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -16,8 +16,8 @@ import type { SourceImageInfo, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { canonicalizeImage } from './canonical.ts' -import type { CanonicalImagePolicy } from './canonical.ts' +import { prepareMasterImage } from './canonical.ts' +import type { MasterImagePolicy } from './canonical.ts' import { detectImage, probeImage } from './image.ts' import type { DetectedImage } from './image.ts' @@ -64,23 +64,60 @@ async function inspectMetadata( /** * Run the full admission policy for one image without touching storage, - * including a canonical-encoding dry run: a batch whose members all validate - * cannot later be refused mid-write by the canonical byte target. + * including master-version preparation: a batch whose members all validate + * cannot later be refused by the master byte cap during publication. * @param input - encoded bytes and declared metadata. * @param limits - resolved source admission policy. - * @param policy - resolved canonical encoding budget. - * @returns completion after the raster has been fully decoded and its canonical encoding proven to fit. + * @param policy - resolved master-version storage policy. + * @returns completion after the raster has been decoded and its master version proven to fit. */ export async function validateImageFile( input: SaveImageAttachment, limits: ImageAttachmentLimits, - policy: CanonicalImagePolicy, + policy: MasterImagePolicy, ): Promise { + await prepareImageFile(input, limits, policy) +} + +/** Fully prepared master object, verified before any batch member is persisted. */ +export interface PreparedImageFile extends SavedImageAttachment { + /** Deterministic master bytes whose digest is {@link ref.attachmentId}. */ + data: Uint8Array +} + +/** + * Decode, normalize, and verify one submitted image without touching storage. + * @param input - submitted encoded bytes and declared media type. + * @param limits - source admission policy. + * @param policy - independent master-version storage policy. + * @returns immutable reference facts beside bytes ready for atomic publication. + */ +export async function prepareImageFile( + input: SaveImageAttachment, + limits: ImageAttachmentLimits, + policy: MasterImagePolicy, +): Promise { if (input.data.byteLength > limits.maxImageBytes) { throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') } - const { detected } = await inspectMetadata(input.data, input.mediaType, limits) - await canonicalizeImage(input.data, detected, policy) + const { detected, source } = await inspectMetadata(input.data, input.mediaType, limits) + const master = await prepareMasterImage(input.data, detected, policy) + const sha256 = digest(master.data) + const name = displayName(input.name) + const downscaled = source.width !== master.width || source.height !== master.height + return { + data: master.data, + ref: { + attachmentId: AttachmentId(`sha256:${sha256}`), + mediaType: master.mediaType, + width: master.width, + height: master.height, + bytes: master.data.byteLength, + ...(name !== undefined ? { name } : {}), + ...downscaled ? { sourceWidth: source.width, sourceHeight: source.height } : {}, + }, + source, + } } /** @@ -143,26 +180,20 @@ async function ensureDurableHome(path: string): Promise { } /** - * Save and verify one image below a versioned attachment root. Admission - * validates the submitted source, then stores its deterministic canonical - * encoding; the returned reference describes the stored canonical bytes while - * `source` preserves the submitted raster's facts. + * Publish one already verified master below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. - * @param input - encoded bytes and declared metadata. - * @param limits - resolved source admission policy. - * @param policy - resolved canonical encoding budget. + * @param prepared - deterministic master bytes, reference, and source facts. * @returns durable content-addressed reference beside the submitted source facts. */ -export async function saveImageFile( +export async function commitPreparedImageFile( root: string, - input: SaveImageAttachment, - limits: ImageAttachmentLimits, - policy: CanonicalImagePolicy, + prepared: PreparedImageFile, ): Promise { - if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') - const { detected, source } = await inspectMetadata(input.data, input.mediaType, limits) - const canonical = await canonicalizeImage(input.data, detected, policy) - const sha256 = digest(canonical.data) + const master = prepared.data + const sha256 = ensureReference(prepared.ref) + if (digest(master) !== sha256 || master.byteLength !== prepared.ref.bytes) { + throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT') + } const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') // Establish DSH_HOME itself against the filesystem root once per process. @@ -176,7 +207,7 @@ export async function saveImageFile( let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(canonical.data) + await handle.writeFile(master) await handle.sync() await handle.close() handle = undefined @@ -211,18 +242,24 @@ export async function saveImageFile( if (error instanceof AttachmentError) throw error throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } - const name = displayName(input.name) - return { - ref: { - attachmentId: AttachmentId(`sha256:${sha256}`), - mediaType: canonical.mediaType, - width: canonical.width, - height: canonical.height, - bytes: canonical.data.byteLength, - ...(name !== undefined ? { name } : {}), - }, - source, - } + return { ref: prepared.ref, source: prepared.source } +} + +/** + * Decode and normalize one image once, then publish the prepared object. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - submitted encoded bytes and declared media type. + * @param limits - resolved source admission policy. + * @param policy - resolved master-version storage policy. + * @returns durable content-addressed reference beside submitted source facts. + */ +export async function saveImageFile( + root: string, + input: SaveImageAttachment, + limits: ImageAttachmentLimits, + policy: MasterImagePolicy, +): Promise { + return commitPreparedImageFile(root, await prepareImageFile(input, limits, policy)) } /** diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/canonical.spec.ts index 12d286a2bc..c1307b5848 100644 --- a/packages/attachment/attachment-local/tests/canonical.spec.ts +++ b/packages/attachment/attachment-local/tests/canonical.spec.ts @@ -1,17 +1,19 @@ import { describe, expect, it } from 'vitest' import sharp from 'sharp' -import { canonicalizeImage, isCanonical } from '../src/canonical.ts' -import type { CanonicalImagePolicy } from '../src/canonical.ts' +import { hasLowColourCount, isMasterImage, prepareMasterImage } from '../src/canonical.ts' +import type { MasterImagePolicy } from '../src/canonical.ts' import { detectImage } from '../src/image.ts' -const POLICY: CanonicalImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } +const POLICY: MasterImagePolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 } /** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */ function noisePixels(width: number, height: number): Uint8Array { const pixels = new Uint8Array(width * height * 3) let state = 0x2545f491 for (let index = 0; index < pixels.length; index += 1) { - state = (state * 1103515245 + 12345) & 0x7fffffff + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 pixels[index] = state & 0xff } return pixels @@ -29,46 +31,64 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | return new Uint8Array(await image.toFormat(format, format === 'webp' && alpha ? { lossless: true } : {}).toBuffer()) } -describe('isCanonical', () => { +describe('isMasterImage', () => { it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => { - const clean = { animated: false, carriesMetadata: false } - expect(isCanonical({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) - expect(isCanonical({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) - expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false }, 100, POLICY)).toBe(false) - expect(isCanonical({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true }, 100, POLICY)).toBe(false) - expect(isCanonical({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) - expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) + const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false } + expect(isMasterImage({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) + expect(isMasterImage({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(isMasterImage({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(isMasterImage({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(isMasterImage({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) + expect(isMasterImage({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) + expect(isMasterImage({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(isMasterImage({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) }) }) -describe('canonicalizeImage', () => { +describe('prepareMasterImage', () => { it('passes an already-canonical source through byte-identically', async () => { const data = await flatImage(6, 4, 'webp') const detected = await detectImage(data) - const canonical = await canonicalizeImage(data, detected, POLICY) + const canonical = await prepareMasterImage(data, detected, POLICY) expect(canonical.data).toBe(data) expect(canonical).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 }) }) + it.each([3, 4] as const)('converts a 16-bit %s-channel PNG to 8-bit sRGB without passthrough', async (channels) => { + const data = new Uint8Array(await sharp({ + create: { width: 7, height: 5, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, + }).toColourspace('rgb16').png().toBuffer()) + const detected = await detectImage(data) + expect(detected).toMatchObject({ depth: 'ushort', space: 'rgb16', hasAlpha: channels === 4 }) + + const canonical = await prepareMasterImage(data, detected, POLICY) + + expect(canonical.data).not.toBe(data) + expect(canonical.data).not.toEqual(data) + await expect(detectImage(canonical.data)).resolves.toMatchObject({ + depth: 'uchar', space: 'srgb', hasAlpha: channels === 4, width: 7, height: 5, + }) + }) + it('downscales an oversized PNG to the long-edge target and stays PNG', async () => { const data = await flatImage(10, 6, 'png') const detected = await detectImage(data) - const canonical = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const canonical = await prepareMasterImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false }) - const again = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + await expect(detectImage(canonical.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + const again = await prepareMasterImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(again.data).toEqual(canonical.data) }) it('re-encodes the canonical output of a resize into itself (idempotence)', async () => { const data = await flatImage(10, 6, 'png') - const first = await canonicalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const first = await prepareMasterImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) - const second = await canonicalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const second = await prepareMasterImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(second.data).toBe(first.data) }) @@ -77,31 +97,66 @@ describe('canonicalizeImage', () => { const data = await flatImage(6, 4, 'gif') const detected = await detectImage(data) - const canonical = await canonicalizeImage(data, detected, POLICY) + const canonical = await prepareMasterImage(data, detected, POLICY) expect(canonical.mediaType).toBe('image/png') - await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false }) + await expect(detectImage(canonical.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) }) - it('keeps alpha sources on PNG when the budget holds', async () => { + it('keeps a low-colour alpha source on PNG when the budget holds', async () => { const data = await flatImage(9, 5, 'webp', true) const detected = await detectImage(data) - const canonical = await canonicalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) + const canonical = await prepareMasterImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) expect(canonical).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) }) + it('retains an all-opaque alpha channel while converting a low-colour image', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 10, height: 6, channels: 4, background: { r: 12, g: 200, b: 64, alpha: 1 } }, + }).png().toBuffer()) + + const canonical = await prepareMasterImage(data, await detectImage(data), { + maxDimension: 5, + maxBytes: POLICY.maxBytes, + }) + + expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) + await expect(detectImage(canonical.data)).resolves.toMatchObject({ hasAlpha: true }) + }) + + it('keeps transparency when the byte cap requires another encoding and smaller dimensions', async () => { + const side = 128 + const pixels = new Uint8Array(side * side * 4) + const noise = noisePixels(side, side) + for (let pixel = 0; pixel < side * side; pixel += 1) { + const target = pixel * 4 + const source = pixel * 3 + pixels[target] = noise[source] ?? 0 + pixels[target + 1] = noise[source + 1] ?? 0 + pixels[target + 2] = noise[source + 2] ?? 0 + pixels[target + 3] = pixel & 0xff + } + const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer()) + + const canonical = await prepareMasterImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) + + expect(canonical.data.byteLength).toBeLessThanOrEqual(1_024) + expect(canonical.width).toBeLessThan(side) + await expect(detectImage(canonical.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) + }) + it('re-encodes an oversized photographic JPEG as JPEG', async () => { const data = await noiseImage(64, 32, 'jpeg') const detected = await detectImage(data) - const canonical = await canonicalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) + const canonical = await prepareMasterImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) expect(canonical).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) }) - it('falls from PNG to the JPEG ladder when palette PNG exceeds the byte target', async () => { + it('classifies a photographic PNG by pixels and uses an opaque photographic encoding', async () => { // A smooth gradient: palette quantization dithers it into a sizable PNG // while JPEG at quality 85 stays far smaller, so the budget between the // two forces exactly one ladder hop. @@ -117,22 +172,23 @@ describe('canonicalizeImage', () => { } const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer()) const detected = await detectImage(data) - const paletteSize = (await sharp(data).png({ compressionLevel: 9, palette: true }).toBuffer()).byteLength - const jpegSize = (await sharp(data).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer()).byteLength - expect(jpegSize).toBeLessThan(paletteSize) - const budget = { maxDimension: 2048, maxBytes: paletteSize - 1 } + const budget = { maxDimension: 128, maxBytes: POLICY.maxBytes } - const canonical = await canonicalizeImage(data, detected, budget) + const canonical = await prepareMasterImage(data, detected, budget) expect(canonical.mediaType).toBe('image/jpeg') + expect(canonical).toMatchObject({ width: 128, height: 128 }) expect(canonical.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) }) - it('refuses a source that no ladder step fits into the byte target', async () => { + it('shrinks dimensions after the quality floor instead of refusing an oversized encoding', async () => { const data = await noiseImage(64, 64, 'png') - await expect(canonicalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 10 })) - .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + const canonical = await prepareMasterImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) + + expect(canonical.data.byteLength).toBeLessThanOrEqual(512) + expect(canonical.width).toBeLessThan(64) + expect(canonical.height).toBeLessThan(64) }) it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => { @@ -143,16 +199,94 @@ describe('canonicalizeImage', () => { // Orientation 6 rotates 90°: the perceived source is 2x4. expect(detected).toMatchObject({ width: 2, height: 4, carriesMetadata: true }) - const canonical = await canonicalizeImage(data, detected, POLICY) + const canonical = await prepareMasterImage(data, detected, POLICY) expect(canonical.data).not.toBe(data) expect(canonical).toMatchObject({ width: 2, height: 4 }) await expect(detectImage(canonical.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false }) }) + it('re-encodes an in-budget image with an ICC profile and strips the profile', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().withIccProfile('p3').toBuffer()) + const detected = await detectImage(data) + expect(detected.carriesMetadata).toBe(true) + + const canonical = await prepareMasterImage(data, detected, POLICY) + + expect(canonical.data).not.toBe(data) + await expect(detectImage(canonical.data)).resolves.toMatchObject({ carriesMetadata: false }) + }) + it('maps an encoder fault on undecodable bytes to a storage failure', async () => { - const detected = { mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false } as const - await expect(canonicalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) - .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + const detected = { + mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false, + depth: 'ushort', space: 'rgb16', hasAlpha: true, + } as const + await expect(prepareMasterImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.', + }) + }) +}) + +describe('hasLowColourCount', () => { + it('distinguishes photographic rasters from low-colour graphics without averaged sampling', async () => { + const side = 512 + const highFrequency = sharp(noisePixels(side, side), { raw: { width: side, height: side, channels: 3 } }) + const gradientPixels = new Uint8Array(side * side * 3) + for (let y = 0; y < side; y += 1) { + for (let x = 0; x < side; x += 1) { + const offset = (y * side + x) * 3 + gradientPixels[offset] = x & 0xff + gradientPixels[offset + 1] = y & 0xff + gradientPixels[offset + 2] = (x * 3 + y * 5) & 0xff + } + } + const ordinaryPhoto = sharp(gradientPixels, { raw: { width: side, height: side, channels: 3 } }) + const solid = sharp({ + create: { width: side, height: side, channels: 3, background: { r: 12, g: 34, b: 56 } }, + }) + const text = sharp(Buffer.from(` + + + DeepSeek 16-bit + + `)) + const transparentData = await sharp({ + create: { width: side, height: side, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }).composite([{ input: Buffer.from(` + + + + `) }]).png().toBuffer() + const transparent = sharp(transparentData) + + await expect(hasLowColourCount(highFrequency)).resolves.toBe(false) + await expect(hasLowColourCount(ordinaryPhoto)).resolves.toBe(false) + await expect(hasLowColourCount(solid)).resolves.toBe(true) + await expect(hasLowColourCount(text)).resolves.toBe(true) + await expect(hasLowColourCount(transparent)).resolves.toBe(true) + }) + + it('keeps an antialiased text screenshot readable on the low-colour PNG path', async () => { + const source = new Uint8Array(await sharp(Buffer.from(` + + + Readable text + + `)).removeAlpha().png().toBuffer()) + + const master = await prepareMasterImage(source, await detectImage(source), { + maxDimension: 512, + maxBytes: POLICY.maxBytes, + }) + const stats = await sharp(master.data).greyscale().stats() + + expect(master).toMatchObject({ mediaType: 'image/png', width: 512, height: 256 }) + expect(stats.channels[0]?.min).toBeLessThan(80) + expect(stats.channels[0]?.max).toBeGreaterThan(240) }) }) diff --git a/packages/attachment/attachment-local/tests/encoding.spec.ts b/packages/attachment/attachment-local/tests/encoding.spec.ts new file mode 100644 index 0000000000..c95d09c43c --- /dev/null +++ b/packages/attachment/attachment-local/tests/encoding.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { CompressionLimiter } from '../src/compression-limiter.ts' +import { encodeFirstWithinLimit } from '../src/encoding.ts' + +describe('lazy image encoding', () => { + it('does not execute fallback qualities after the first fitting candidate', async () => { + const first = vi.fn(() => Promise.resolve({ data: new Uint8Array(8), quality: 85 })) + const fallback = vi.fn(() => Promise.resolve({ data: new Uint8Array(4), quality: 80 })) + + await expect(encodeFirstWithinLimit([first, fallback], 8)).resolves.toMatchObject({ quality: 85 }) + expect(first).toHaveBeenCalledTimes(1) + expect(fallback).not.toHaveBeenCalled() + }) + + it('executes later candidates only after earlier candidates exceed the cap', async () => { + const first = vi.fn(() => Promise.resolve({ data: new Uint8Array(12), quality: 85 })) + const second = vi.fn(() => Promise.resolve({ data: new Uint8Array(7), quality: 80 })) + const third = vi.fn(() => Promise.resolve({ data: new Uint8Array(5), quality: 75 })) + + await expect(encodeFirstWithinLimit([first, second, third], 8)).resolves.toMatchObject({ quality: 80 }) + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + expect(third).not.toHaveBeenCalled() + }) +}) + +describe('CompressionLimiter', () => { + it('starts at most the configured number of tasks and preserves queued progress', async () => { + const limiter = new CompressionLimiter(2) + const gates = Array.from({ length: 4 }, () => Promise.withResolvers()) + let active = 0 + let maximum = 0 + const started: number[] = [] + const tasks = gates.map((gate, index) => limiter.run(async () => { + active += 1 + maximum = Math.max(maximum, active) + started.push(index) + await gate.promise + active -= 1 + return index + })) + + await Promise.resolve() + expect(started).toEqual([0, 1]) + gates[0]!.resolve(undefined) + await tasks[0] + await Promise.resolve() + expect(started).toEqual([0, 1, 2]) + gates[1]!.resolve(undefined) + gates[2]!.resolve(undefined) + await Promise.all([tasks[1], tasks[2]]) + await Promise.resolve() + expect(started).toEqual([0, 1, 2, 3]) + gates[3]!.resolve(undefined) + + await expect(Promise.all(tasks)).resolves.toEqual([0, 1, 2, 3]) + expect(maximum).toBe(2) + }) + + it('releases a slot when a task throws before returning a promise', async () => { + const limiter = new CompressionLimiter(1) + const failed = limiter.run(() => { + throw new Error('synchronous setup failure') + }) + const next = limiter.run(() => Promise.resolve('next')) + + await expect(failed).rejects.toThrow('synchronous setup failure') + await expect(next).resolves.toBe('next') + }) +}) diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index 4398f986b7..848aa3ea28 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -18,7 +18,7 @@ describe('raster decoding', () => { ['gif', 'image/gif'], ] as const) { await expect(detectImage(await raster(format))) - .resolves.toEqual({ mediaType, width: 3, height: 2, animated: false, carriesMetadata: false }) + .resolves.toMatchObject({ mediaType, width: 3, height: 2, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) } }) @@ -31,7 +31,7 @@ describe('raster decoding', () => { await expect(detectImage(await raster('png'), { maxDimension: 2 })) .rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) await expect(detectImage(await raster('png'), { maxDimension: 3 })) - .resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2, animated: false, carriesMetadata: false }) + .resolves.toMatchObject({ mediaType: 'image/png', width: 3, height: 2, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) }) it('rejects malformed bytes and truncated payloads with readable headers', async () => { @@ -56,18 +56,30 @@ describe('raster decoding', () => { const oriented = new Uint8Array(await sharp({ create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, }).jpeg().withMetadata({ orientation: 6 }).toBuffer()) - await expect(detectImage(oriented)).resolves.toEqual({ + await expect(detectImage(oriented)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 2, height: 4, animated: false, carriesMetadata: true, }) const flipped = new Uint8Array(await sharp({ create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, }).jpeg().withMetadata({ orientation: 3 }).toBuffer()) - await expect(detectImage(flipped)).resolves.toEqual({ + await expect(detectImage(flipped)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 4, height: 2, animated: false, carriesMetadata: true, }) }) + it('reports color profiles and encoder metadata as metadata', async () => { + const profiled = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().withIccProfile('p3').toBuffer()) + await expect(detectImage(profiled)).resolves.toMatchObject({ carriesMetadata: true }) + + const commented = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().withMetadata().toBuffer()) + await expect(detectImage(commented)).resolves.toMatchObject({ carriesMetadata: true }) + }) + it('probes malformed bytes and unsupported formats into the same stable error', async () => { await expect(probeImage(Uint8Array.of(1, 2, 3))) .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index c4be530480..872aa5a3f7 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -4,9 +4,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import sharp from 'sharp' import LocalAttachmentStore, { - DEFAULT_CANONICAL_MAX_BYTES, - DEFAULT_CANONICAL_MAX_DIMENSION, + DEFAULT_MASTER_MAX_BYTES, + DEFAULT_MASTER_MAX_DIMENSION, + DEFAULT_IMAGE_COMPRESSION_CONCURRENCY, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_IMAGE_DIMENSION, DEFAULT_MAX_IMAGE_PIXELS, @@ -26,10 +28,19 @@ describe('local attachment service', () => { maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) - expect(service.canonicalPolicy).toEqual({ - maxDimension: DEFAULT_CANONICAL_MAX_DIMENSION, - maxBytes: DEFAULT_CANONICAL_MAX_BYTES, + expect(service.masterPolicy).toEqual({ + maxDimension: DEFAULT_MASTER_MAX_DIMENSION, + maxBytes: DEFAULT_MASTER_MAX_BYTES, }) + expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY) + }) + + it('resolves and validates the instance image-compression concurrency', () => { + expect(new LocalAttachmentStore(new Context(), { imageCompressionConcurrency: 1 }).imageCompressionConcurrency).toBe(1) + for (const imageCompressionConcurrency of [0, 1.5, 9]) { + expect(() => new LocalAttachmentStore(new Context(), { imageCompressionConcurrency })) + .toThrow(/imageCompressionConcurrency must be an integer from 1 through 8/) + } }) it('saves and reads through the service boundary', async () => { @@ -37,7 +48,7 @@ describe('local attachment service', () => { try { const service = new LocalAttachmentStore(new Context(), { dshHome }) const data = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) const { ref } = await service.saveImage({ data, mediaType: 'image/png' }) @@ -47,12 +58,31 @@ describe('local attachment service', () => { } }) - it('refuses a batch during validation when a member cannot meet the canonical byte target, before any write', async () => { + it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit master object', async (channels) => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-16-bit-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const source = new Uint8Array(await sharp({ + create: { width: 7, height: 5, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, + }).toColourspace('rgb16').png().toBuffer()) + + const saved = await service.saveImage({ data: source, mediaType: 'image/png' }) + const stored = await service.readImage(saved.ref) + const metadata = await sharp(stored.data).metadata() + + expect(stored.data).not.toEqual(source) + expect(metadata).toMatchObject({ depth: 'uchar', space: 'srgb', hasAlpha: channels === 4 }) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) + + it('prepares every batch member before any write', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-')) try { - const service = new LocalAttachmentStore(new Context(), { dshHome, canonicalMaxBytes: 10 }) + const service = new LocalAttachmentStore(new Context(), { dshHome, masterMaxBytes: 1 }) const valid = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) await expect(service.saveImages([ @@ -72,7 +102,7 @@ describe('local attachment service', () => { await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' })) .rejects.toThrow(/Unsupported or malformed image data/) const valid = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 }) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts new file mode 100644 index 0000000000..69bcfdf36c --- /dev/null +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -0,0 +1,209 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import sharp from 'sharp' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CompressionLimiter } from '../src/compression-limiter.ts' +import LocalAttachmentStore, { previewCropToMaster, requestImageDimensions } from '../src/index.ts' + +const homes: string[] = [] + +async function store(): Promise { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-image-')) + homes.push(dshHome) + return new LocalAttachmentStore(new Context(), { dshHome }) +} + +async function image(width: number, height: number): Promise { + return new Uint8Array(await sharp({ + create: { width, height, channels: 3, background: { r: 12, g: 34, b: 56 } }, + }).png().toBuffer()) +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) +}) + +describe('request image dimensions', () => { + it.each([ + [4096, 4096, 800, 800], + [4096, 2048, 1130, 565], + [3840, 2160, 1066, 600], + [320, 240, 320, 240], + ])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => { + const projected = requestImageDimensions(width, height, 640_000) + expect(projected).toEqual({ + width: expectedWidth, + height: expectedHeight, + }) + expect(projected.width * projected.height).toBeLessThanOrEqual(640_000) + }) +}) + +describe('local request-image cache', () => { + it('derives stable square and wide previews and separates route budgets in the cache key', async () => { + const attachments = await store() + const square = (await attachments.saveImage({ + data: await image(2048, 2048), mediaType: 'image/png', name: 'square.png', + })).ref + const wide = (await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png', + })).ref + + const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }) + + expect(squareRequest).toMatchObject({ width: 800, height: 800 }) + expect(wideRequest).toMatchObject({ width: 1130, height: 565 }) + expect(repeated.variantId).toBe(wideRequest.variantId) + expect(repeated.data).toEqual(wideRequest.data) + expect(Buffer.from(repeated.data).toString('base64')).toBe(Buffer.from(wideRequest.data).toString('base64')) + expect(low.variantId).not.toBe(wideRequest.variantId) + expect(low.width * low.height).toBeLessThanOrEqual(512 * 512 + low.width) + }) + + it('maps preview coordinates to the 2048px master and crops the master instead of the preview', async () => { + const attachments = await store() + const pixels = Buffer.alloc(2048 * 1024 * 3) + for (let y = 0; y < 1024; y += 1) { + for (let x = 0; x < 2048; x += 1) { + const offset = (y * 2048 + x) * 3 + pixels[offset] = x < 1024 ? 255 : 0 + pixels[offset + 1] = x < 1024 ? 0 : 255 + pixels[offset + 2] = 0 + } + } + const source = new Uint8Array(await sharp(pixels, { raw: { width: 2048, height: 1024, channels: 3 } }).png().toBuffer()) + const master = (await attachments.saveImage({ data: source, mediaType: 'image/png', name: 'halves.png' })).ref + const preview = await attachments.readImageRequest(master, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const previewCrop = { + previewWidth: preview.width, + previewHeight: preview.height, + x: Math.floor(preview.width / 2), + y: 0, + width: preview.width - Math.floor(preview.width / 2), + height: preview.height, + } + const mapped = previewCropToMaster(master.width, master.height, previewCrop) + + const cropped = await attachments.cropImage(master, previewCrop) + const stored = await attachments.readImage(cropped.ref) + const pixel = await sharp(stored.data).resize(1, 1).removeAlpha().raw().toBuffer() + + expect(mapped).toEqual({ x: 1024, y: 0, width: 1024, height: 1024 }) + expect(cropped.ref.width).toBe(mapped.width) + expect(cropped.ref.height).toBe(mapped.height) + expect(pixel[1]).toBeGreaterThan(pixel[0] ?? 0) + }) + + it('classifies opaque PNG pixels and preserves alpha while enforcing the request budget', async () => { + const attachments = await store() + const side = 256 + const photoPixels = new Uint8Array(side * side * 3) + const alphaPixels = new Uint8Array(side * side * 4) + let state = 0x2545f491 + for (let pixel = 0; pixel < side * side; pixel += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + const photo = pixel * 3 + const alpha = pixel * 4 + photoPixels[photo] = state & 0xff + photoPixels[photo + 1] = state >> 8 & 0xff + photoPixels[photo + 2] = state >> 16 & 0xff + alphaPixels[alpha] = photoPixels[photo] ?? 0 + alphaPixels[alpha + 1] = photoPixels[photo + 1] ?? 0 + alphaPixels[alpha + 2] = photoPixels[photo + 2] ?? 0 + alphaPixels[alpha + 3] = pixel & 0xff + } + const photoSource = new Uint8Array(await sharp(photoPixels, { + raw: { width: side, height: side, channels: 3 }, + }).png().toBuffer()) + const alphaSource = new Uint8Array(await sharp(alphaPixels, { + raw: { width: side, height: side, channels: 4 }, + }).png().toBuffer()) + const photo = (await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })).ref + const alpha = (await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })).ref + + const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 }) + const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) + + expect(photoRequest.mediaType).toBe('image/jpeg') + expect(alphaRequest.bytes).toBeLessThanOrEqual(4_096) + expect(alphaRequest.width).toBeLessThan(128) + await expect(sharp(alphaRequest.data).metadata()).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) + }) + + it.each([3, 4] as const)('projects a 16-bit %s-channel PNG as a bounded 8-bit request image', async (channels) => { + const attachments = await store() + const source = new Uint8Array(await sharp({ + create: { width: 64, height: 32, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, + }).toColourspace('rgb16').png().toBuffer()) + const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + + const request = await attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + + expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) + expect(request.width * request.height).toBeLessThanOrEqual(16 * 16) + await expect(sharp(request.data).metadata()).resolves.toMatchObject({ + depth: 'uchar', space: 'srgb', hasAlpha: channels === 4, + }) + }) + + it('retains an all-opaque alpha channel in a resized request version', async () => { + const attachments = await store() + const source = new Uint8Array(await sharp({ + create: { width: 64, height: 32, channels: 4, background: { r: 12, g: 34, b: 56, alpha: 1 } }, + }).png().toBuffer()) + const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + + const request = await attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + + await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: true }) + }) + + it('keeps a complex 640,000-pixel request version below 1 MiB', async () => { + const attachments = await store() + const side = 1024 + const pixels = new Uint8Array(side * side * 3) + let state = 0x6d2b79f5 + for (let index = 0; index < pixels.length; index += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + pixels[index] = state & 0xff + } + const source = new Uint8Array(await sharp(pixels, { + raw: { width: side, height: side, channels: 3 }, + }).png().toBuffer()) + const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + + const request = await attachments.readImageRequest(master, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + + expect(request).toMatchObject({ width: 800, height: 800 }) + expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) + }) + + it('shares one request transform between concurrent callers without sharing cancellation', async () => { + const attachments = await store() + const master = (await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'shared.png', + })).ref + const run = vi.spyOn(CompressionLimiter.prototype, 'run') + const controller = new AbortController() + const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + + const cancelled = attachments.readImageRequest(master, policy, controller.signal) + const completed = attachments.readImageRequest(master, policy) + const reason = new Error('cancel one waiter') + controller.abort(reason) + + await expect(cancelled).rejects.toBe(reason) + await expect(completed).resolves.toMatchObject({ width: 1130, height: 565 }) + expect(run).toHaveBeenCalledTimes(1) + run.mockRestore() + }) +}) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 8fdd076f6e..97445c2f85 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -7,7 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { CanonicalImagePolicy } from '../src/canonical.ts' +import type { MasterImagePolicy } from '../src/canonical.ts' import { readImageFile, saveImageFile } from '../src/store.ts' const fsControl = vi.hoisted(() => ({ @@ -35,11 +35,11 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const PNG = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) -const POLICY: CanonicalImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } +const POLICY: MasterImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, @@ -139,7 +139,7 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first.ref)).resolves.toEqual({ ref: first.ref, data: PNG }) }) - it('stores the canonical encoding of an oversized source and reads it back verified', async () => { + it('stores the image master of an oversized source and reads it back verified', async () => { const storageRoot = await root() const oversized = new Uint8Array(await sharp({ create: { width: 4, height: 4, channels: 3, background: { r: 9, g: 9, b: 9 } }, diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 9c61d2fe81..221699165d 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.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 packages/attachment/attachment/README.md -README.md: 3b80444804a345bd019fe94f25954933aa549518 -README.zh.md: 37be4a4a9f54a7e7ddb5fdceb57711378c2f2cfc +README.md: c4925addf079cdd65defb733e6bc40f91ed6384f +README.zh.md: 5623e0944c6f67e2cdaa90076d794cd617c46d5f diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 3b80444804..c4925addf0 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,15 +2,15 @@ English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent master image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting, including any canonical-encoding dry run the implementation applies, so batch validation proves every member can also be committed. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: an implementation may persist a canonical re-encoding of the submitted raster, so the returned `ref` always describes the stored bytes while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and dimensions for callers that report or map coordinates against the original. `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every validated master once before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: the returned `ref` describes the stored master while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and orientation-applied dimensions. `readImage` verifies that master against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the master id, transform version, pixel and byte budgets, crop, and encoder settings; `readImageRequests` preserves ordered results while implementations apply their own bounded concurrency. `cropImage` maps preview coordinates to the stored master and persists the result as a new attachment. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference. +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id, actual preview dimensions, and the `read_image_region` coordinate system. #### KV Cache effect diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 37be4a4a9f..5623e0944c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的图片主版本,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整的准入策略但不执行持久化,包含实现所应用的规范编码干跑,因此批量校验能证明每个成员随后也能提交成功。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:实现可以持久保存所提交光栅的规范重编码,因此返回的 `ref` 始终描述实际存储的字节,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和尺寸,供需要对照原图汇报或换算坐标的调用方使用。`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前为全部成员各准备一次经过验证的主版本,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:返回的 `ref` 描述实际存储的主版本,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和应用方向后的尺寸。`readImage` 根据已记录的元数据校验该主版本。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖主版本 ID、变换策略版本、像素和字节预算、裁剪区域及编码参数;`readImageRequests` 保持结果顺序,并由实现施加自己的有界并发。`cropImage` 把预览坐标映射到存储的主版本,并把结果保存为新附件。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。 +该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID、实际预览尺寸和 `read_image_region` 使用的坐标系。 #### KV 缓存影响 diff --git a/packages/attachment/attachment/src/brand.ts b/packages/attachment/attachment/src/brand.ts index 6df4014f74..e783076982 100644 --- a/packages/attachment/attachment/src/brand.ts +++ b/packages/attachment/attachment/src/brand.ts @@ -13,3 +13,15 @@ export type AttachmentId = Branded<'AttachmentId'> export function AttachmentId(value: string): AttachmentId { return value as AttachmentId } + +/** Opaque deterministic identity for one request-image transformation. */ +export type ImageVariantId = Branded<'ImageVariantId'> + +/** + * Brand a validated request-image transformation identifier. + * @param value - attachment-provider-produced opaque identifier. + * @returns the branded identifier. + */ +export function ImageVariantId(value: string): ImageVariantId { + return value as ImageVariantId +} diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 2e2d695dae..c19229872b 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -23,6 +23,7 @@ export type AttachmentErrorCode = | 'ATTACHMENT_WRITE_FAILED' | 'ATTACHMENT_NOT_FOUND' | 'ATTACHMENT_READ_FAILED' + | 'ATTACHMENT_PROJECTION_UNSUPPORTED' /** Runtime membership for structurally compatible errors crossing package boundaries. */ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISSION_ERROR_CODES) diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 8b3f81a98f..705346d4cc 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -5,12 +5,15 @@ import { AttachmentError } from './error.ts' import type { ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, + PreviewImageCrop, + RequestImageAttachment, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment, } from './types.ts' -export { AttachmentId } from './brand.ts' +export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedImages } from './admission.ts' @@ -19,7 +22,11 @@ export type { EncodedImageAttachment, ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, ImageMediaType, + MasterImageCrop, + PreviewImageCrop, + RequestImageAttachment, SaveImageAttachment, SavedImageAttachment, SourceImageInfo, @@ -57,7 +64,7 @@ export abstract class AttachmentStore extends Service { * @param inputs - encoded images in their owning message order. * @returns durable references in the exact input order. */ - async saveImages(inputs: readonly SaveImageAttachment[]): Promise { + protected validateImageBatch(inputs: readonly SaveImageAttachment[]): void { const { maxImagesPerMessage, maxMessageImageBytes, mediaTypes } = this.imageLimits if (inputs.length > maxImagesPerMessage) { throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') @@ -71,6 +78,15 @@ export abstract class AttachmentStore extends Service { throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE') } } + } + + /** + * Validate and durably commit one ordered image batch. + * @param inputs - encoded images in owning-message order. + * @returns durable master references in the same order after every member succeeds. + */ + async saveImages(inputs: readonly SaveImageAttachment[]): Promise { + this.validateImageBatch(inputs) for (const input of inputs) await this.validateImage(input) const refs: ImageAttachmentRef[] = [] @@ -80,7 +96,7 @@ export abstract class AttachmentStore extends Service { /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a canonical re-encoding of the submitted raster; + * Implementations may store a prepared master version of the submitted raster; * the returned reference always describes the stored bytes, while `source` * preserves the submitted raster's intrinsic facts for callers that report * or map coordinates against the original. @@ -93,10 +109,70 @@ export abstract class AttachmentStore extends Service { * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and canonical reference. + * @returns the verified bytes and master reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + + /** + * Generate or read one deterministic model-request version from the stored master image. + * @param ref - durable provider-independent master reference. + * @param policy - exact route pixel and encoded-byte budget. + * @param signal - optional cancellation. + * @returns request bytes and the cache/upload identity covering every transform input. + */ + readImageRequest( + ref: ImageAttachmentRef, + policy: ImageRequestPolicy, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted() + void ref + void policy + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot derive model-request images.', + 'ATTACHMENT_PROJECTION_UNSUPPORTED', + )) + } + + /** + * Generate or read an ordered batch of deterministic model-request versions. + * Implementations may use their own bounded transform concurrency while preserving input order. + * @param refs - durable provider-independent master references in request order. + * @param policy - exact route pixel and encoded-byte budget shared by the batch. + * @param signal - optional cancellation. + * @returns request versions in the same order as `refs`. + */ + async readImageRequests( + refs: readonly ImageAttachmentRef[], + policy: ImageRequestPolicy, + signal?: AbortSignal, + ): Promise { + const versions: RequestImageAttachment[] = [] + for (const ref of refs) versions.push(await this.readImageRequest(ref, policy, signal)) + return versions + } + + /** + * Crop the stored master by coordinates measured on a model request preview and persist the result. + * @param ref - session-authorized master attachment. + * @param crop - preview dimensions and preview-coordinate rectangle. + * @param signal - optional cancellation. + * @returns a new durable attachment reference suitable for a logged tool result. + */ + cropImage( + ref: ImageAttachmentRef, + crop: PreviewImageCrop, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted() + void ref + void crop + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot crop stored images.', + 'ATTACHMENT_PROJECTION_UNSUPPORTED', + )) + } } export default AttachmentStore diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 22db4c6d23..1d83cf1afa 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -1,6 +1,6 @@ /** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */ -import type { AttachmentId } from './brand.ts' +import type { AttachmentId, ImageVariantId } from './brand.ts' export type { AttachmentId } from './brand.ts' @@ -21,6 +21,10 @@ export interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string + /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ + sourceWidth?: number + /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ + sourceHeight?: number } /** Deployment-resolved limits used by upload admission and request buffering. */ @@ -59,7 +63,57 @@ export interface StoredImageAttachment { data: Uint8Array } -/** Intrinsic facts of the submitted source raster, before any canonical re-encoding. */ +/** Pixel rectangle in the oriented 2048px master-version coordinate system. */ +export interface MasterImageCrop { + x: number + y: number + width: number + height: number +} + +/** Deterministic request-image policy selected by one exact model route. */ +export interface ImageRequestPolicy { + /** Maximum width multiplied by height after aspect-preserving projection. */ + maxPixels: number + /** Encoded-byte cap before base64 expansion or Files API upload. */ + maxBytes: number + /** Optional master-coordinate crop applied before pixel-budget scaling. */ + crop?: MasterImageCrop +} + +/** Cached request version derived from one provider-independent master attachment. */ +export interface RequestImageAttachment { + /** Cache and upload-index key over the master id, policy, crop, and fixed encoder parameters. */ + variantId: ImageVariantId + /** Durable master reference from which this request version was derived. */ + master: ImageAttachmentRef + /** Encoded request bytes. */ + data: Uint8Array + mediaType: ImageMediaType + bytes: number + width: number + height: number + /** Provider-compatible sample depth proven after request encoding. */ + depth: 'uchar' + /** Provider-compatible color space proven after request encoding. */ + space: 'srgb' + /** Whether the encoded request version retains an alpha channel. */ + hasAlpha: boolean + /** Applied master-coordinate crop, when present. */ + crop?: MasterImageCrop +} + +/** Crop coordinates measured by a model on the request preview it received. */ +export interface PreviewImageCrop { + previewWidth: number + previewHeight: number + x: number + y: number + width: number + height: number +} + +/** Intrinsic facts of the submitted source raster, before master-version preparation. */ export interface SourceImageInfo { /** Media type verified from the submitted bytes. */ mediaType: ImageMediaType diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index b3460a77ab..3a8fa23cbe 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest' import AttachmentStore, { AttachmentError, AttachmentId, + ImageVariantId, isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, + type ImageRequestPolicy, + type RequestImageAttachment, type SaveImageAttachment, type SavedImageAttachment, type StoredImageAttachment, @@ -52,6 +55,25 @@ class RecordingStore extends AttachmentStore { readImage(_ref: ImageAttachmentRef): Promise { throw new Error('not used') } + + override readImageRequest( + ref: ImageAttachmentRef, + _policy: ImageRequestPolicy, + ): Promise { + this.calls.push(`request:${ref.name}`) + return Promise.resolve({ + variantId: ImageVariantId(`sha256:${String(ref.bytes).padStart(64, '0')}`), + master: ref, + data: Uint8Array.of(ref.bytes), + mediaType: ref.mediaType, + bytes: 1, + width: ref.width, + height: ref.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: false, + }) + } } function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment { @@ -101,6 +123,19 @@ describe('AttachmentStore.saveImages', () => { }) }) +describe('AttachmentStore.readImageRequests', () => { + it('uses the default serial projection and preserves input order', async () => { + const store = new RecordingStore(new Context()) + const refs = await store.saveImages([image(1), image(2)]) + store.calls.length = 0 + + const versions = await store.readImageRequests(refs, { maxPixels: 1, maxBytes: 1 }) + + expect(store.calls).toEqual(['request:1.png', 'request:2.png']) + expect(versions.map(version => version.master.name)).toEqual(['1.png', '2.png']) + }) +}) + describe('isImageAdmissionError', () => { it('separates caller-correctable image admission failures from storage faults', () => { expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 60f8ac66f3..ad5d04fd5d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -438,13 +438,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveImages(inputs: readonly SaveImageAttachment[]): Promise', - description: 'Validate one ordered image batch before committing any member. Validation failures start no writes; storage failures return no partial references, although already published content-addressed objects may stay unreachable until a future retention policy collects them.', - parameters: [{ name: 'inputs', description: 'encoded images in their owning message order.' }], - returns: 'durable references in the exact input order.', + description: 'Validate and durably commit one ordered image batch.', + parameters: [{ name: 'inputs', description: 'encoded images in owning-message order.' }], + returns: 'durable master references in the same order after every member succeeds.', }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', - description: 'Validate and durably commit one image before its owning session event is appended. Implementations may store a canonical re-encoding of the submitted raster; the returned reference always describes the stored bytes, while `source` preserves the submitted raster\'s intrinsic facts for callers that report or map coordinates against the original.', + description: 'Validate and durably commit one image before its owning session event is appended. Implementations may store a prepared master version of the submitted raster; the returned reference always describes the stored bytes, while `source` preserves the submitted raster\'s intrinsic facts for callers that report or map coordinates against the original.', parameters: [{ name: 'input', description: 'encoded bytes, declared media type, and optional display name.' }], returns: 'the durable content-addressed reference beside the submitted source facts.', }, @@ -452,9 +452,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise', description: 'Read one image and verify that bytes still match the recorded reference.', parameters: [{ name: 'ref', description: 'durable reference from the session log.' }, { name: 'signal', description: 'optional cancellation for backend read and verification work.' }], - returns: 'the verified bytes and canonical reference.', + returns: 'the verified bytes and master reference.', throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, + { + signature: 'async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', + description: 'Generate or read one deterministic model-request version from the stored master image.', + parameters: [{ name: 'ref', description: 'durable provider-independent master reference.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], + returns: 'request bytes and the cache/upload identity covering every transform input.', + }, + { + signature: 'async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', + description: 'Generate or read an ordered batch of deterministic model-request versions. Implementations may use their own bounded transform concurrency while preserving input order.', + parameters: [{ name: 'refs', description: 'durable provider-independent master references in request order.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget shared by the batch.' }, { name: 'signal', description: 'optional cancellation.' }], + returns: 'request versions in the same order as `refs`.', + }, + { + signature: 'async cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise', + description: 'Crop the stored master by coordinates measured on a model request preview and persist the result.', + parameters: [{ name: 'ref', description: 'session-authorized master attachment.' }, { name: 'crop', description: 'preview dimensions and preview-coordinate rectangle.' }, { name: 'signal', description: 'optional cancellation.' }], + returns: 'a new durable attachment reference suitable for a logged tool result.', + }, ], }, { @@ -3450,7 +3468,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageAttachmentRef', - declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n}', + declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n sourceWidth?: number;\n sourceHeight?: number;\n}', }, { name: 'ImageBlock', @@ -3460,6 +3478,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ImageMediaType', declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', }, + { + name: 'ImageRequestPolicy', + declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n crop?: MasterImageCrop;\n}', + }, + { + name: 'ImageVariantId', + declaration: 'export type ImageVariantId = Branded<\'ImageVariantId\'>;', + }, { name: 'Inbox', declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', @@ -3586,7 +3612,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmAdapter', - declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n listModels(_provider: string): Promise;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n listModels(_provider: string): Promise;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise;\n async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'LlmCallConfig', @@ -3684,6 +3710,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ManualCompactAgentContext', declaration: 'export interface ManualCompactAgentContext extends CompactionAgentContext {\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n}', }, + { + name: 'MasterImageCrop', + declaration: 'export interface MasterImageCrop {\n x: number;\n y: number;\n width: number;\n height: number;\n}', + }, { name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', @@ -3804,9 +3834,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PostToolDecision', declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', }, + { + name: 'PreparedAdapterCall', + declaration: 'export interface PreparedAdapterCall {\n readonly model: LlmResolvedModelInfo;\n stream(options: GenerateOptions): AsyncIterable;\n}', + }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n readonly context?: LlmModelContext;\n readonly inputModalities?: readonly ModelModality[];\n readonly adapterDefaults: LlmCallConfigAdapterDefaults;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'PreparedReferencedMessage', @@ -3836,6 +3870,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n} | {\n kind: \'ask\';\n reason?: string;\n};', }, + { + name: 'PreviewImageCrop', + declaration: 'export interface PreviewImageCrop {\n previewWidth: number;\n previewHeight: number;\n x: number;\n y: number;\n width: number;\n height: number;\n}', + }, { name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', @@ -3916,6 +3954,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RequestHeaderReason', declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', }, + { + name: 'RequestImageAttachment', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n master: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n crop?: MasterImageCrop;\n}', + }, { name: 'RequestRunOutcome', declaration: 'export type RequestRunOutcome = \'approved\' | \'completed\' | \'rejected\' | \'cancelled\' | \'failed\';', diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 3d9c4606c4..47084a3435 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.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 packages/fs/tool-fs/README.md -README.md: 22384ddb18f2b36e9b8a177ee62eed9424ddcd6a -README.zh.md: 74c41f4f25d19089c40a52cff4e3dfa654630b1a +README.md: 94af10c501bcb86465d685f1f20c7d42f3b9d117 +README.zh.md: 4b8e826db3ae15b825d2f888e7d37fc3cafd1b23 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 22384ddb18..94af10c501 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **model-facing filesystem tools** — `read`, `read_image`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. +The **model-facing filesystem tools** — `read`, `read_image`, `read_image_region`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. @@ -14,7 +14,7 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-fs-observation-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. -`read_image` registers only while a durable `ctx.attachments` service is mounted — without one the deployment cannot commit image bytes, so the tool never appears. Execution additionally requires the exact routed model to declare `image` input (resolved through `ctx.llm.resolveModelInfo` from the session's latest request header, falling back to agent options); an unknown or text-only route gets a refusal result before any filesystem I/O, so a text route's durable history stays free of image blocks. +`read_image` and `read_image_region` register only while a durable `ctx.attachments` service is mounted. Execution additionally requires the exact routed model to declare `image` input (resolved through `ctx.llm.resolveModelInfo` from the session's latest request header, falling back to agent options). `read_image_region` accepts only a complete attachment id already referenced by the calling session, so it can crop a user upload without a filesystem path but cannot cross session scope. ## Config @@ -33,12 +33,13 @@ All keys are optional; the defaults are the shipped read caps. |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | | `read_image` | `file_path` | Reads a PNG/JPEG/WebP/GIF file through the bounded byte seam, persists it through `ctx.attachments.saveImage`, and returns an image block beside a small metadata envelope. It succeeds only when the exact routed model declares image input. | +| `read_image_region` | `attachment_id`, `preview_width`, `preview_height`, `x`, `y`, `width`, `height` | Resolves a session-authorized image, maps the preview-coordinate rectangle to its stored master, persists the crop, and returns the new image block. | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }` (the source fields appear only when the attachment store's canonical encoding downscaled the file, and the envelope then names the coordinate multiplier back to the original), `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. +Structured successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`, `read_image_region` → `{ sourceAttachmentId, preview, crop, image }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. The image source fields appear only when master preparation downscaled the submitted raster. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`; execution-local structured values are not added to `tool/result`, while image renderers emit the durable image blocks that the result logs. ## The tool is the executor; policy is an event gate @@ -46,6 +47,7 @@ The tools do **not** inject a policy service or inspect any cache. Each tool res - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) - **read_image** — validates the argument, extension, attachment availability, deployment media types, and the image-capable route before any I/O; then one `ctx.fs.stat` (recording an `absent` observation for a missing target, like `read`), a bounded `ctx.fs.readBytes` capped at the smaller of `imageLimits.maxImageBytes` and `imageLimits.maxMessageImageBytes` (the result is one message carrying one image), `attachments.saveImage` (content-addressed, so the image block references a durably committed object by the time `tool/result` is appended), and finally `fs/observed`. (1 stat.) +- **read_image_region** — resolves the full attachment id only from current session messages, validates integer preview coordinates, maps the rectangle to the stored master through `attachments.cropImage`, and returns the persisted crop as an image block. It performs no filesystem-path operation and emits no `fs/observed` event. - **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) - **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) @@ -99,7 +101,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr #### What the model sees -The model sees the generated [`read`, `read_image`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. `read_image` appears only while a durable attachment store is mounted; the schema itself is route-independent, and the strict gate refuses at execution. Scoped tool restrictions can remove any definition for one agent. +The model sees the generated [`read`, `read_image`, `read_image_region`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. The image tools appear only while a durable attachment store is mounted; their schemas are route-independent, and the strict gate refuses at execution. Scoped tool restrictions can remove any definition for one agent. #### Token effect @@ -127,7 +129,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, dimensions, and byte size, followed by the image itself as a native image block. The session log stores only the durable `sha256:` attachment reference; the routed provider re-reads and digest-verifies the bytes on each request. +A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, master dimensions, and byte size, followed by the image itself as a native image block. A successful `read_image_region` returns an `image-region` envelope naming the source attachment, supplied preview dimensions and rectangle, and result dimensions, followed by the crop as a native image block. The result is logged with its new durable reference before the next model request. Request adapters derive previews from the master, so later region reads never crop an already reduced preview. #### Token effect @@ -155,7 +157,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`. A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Region reads reject empty or out-of-scope attachment ids and invalid preview rectangles before storage mutation. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect @@ -169,7 +171,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies ripgrep-backed `glob` and `grep` rather than extending the filesystem seam. - **`read` handles UTF-8 text files only** — images use the separate extension-routed `read_image` tool; PDF, audio, and video remain deferred. A directory target is `FS_NOT_REGULAR_FILE`. -- **The route gate races a concurrent model switch** — `read_image` checks the latest routed model at execution; a switch committed between that check and the next request can leave an image block on a route that rejects image content. The Web host already refuses switching an image-bearing session to a text-only model; other front doors own their equivalent guard. - **Extension-declared media type** — the extension selects the declared type and the attachment store's magic-byte validation stays authoritative; a correctly formatted image under a wrong extension is refused with the rename remedy rather than sniffed. - **No inline image preview on the tool-result card** — UI surfaces render the image result generically (the durable reference, not pixels); inline rendering is deferred to the UI packages. - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 74c41f4f25..4b8e826db3 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统工具**(`read`、`read_image`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 +**面向模型的文件系统工具**(`read`、`read_image`、`read_image_region`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取、写入和编辑。新鲜度与观察策略由独立插件([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. @@ -14,7 +14,7 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-fs-observation-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供写入/编辑前读取行为。 -`read_image` 只在持久 `ctx.attachments` 服务已挂载时注册:没有它,部署无法持久提交图像字节,工具就不会出现。执行时还要求确切路由的模型声明 `image` 输入(通过 `ctx.llm.resolveModelInfo` 从会话最新请求 header 解析,缺失时回退到 agent 选项);未知或纯文本路由在任何文件系统 I/O 之前就得到拒绝结果,因此文本路由的持久历史不会出现图像块。 +`read_image` 和 `read_image_region` 只在持久 `ctx.attachments` 服务已挂载时注册。执行时还要求确切路由的模型声明 `image` 输入,通过 `ctx.llm.resolveModelInfo` 从会话最新请求 header 解析,缺失时回退到 agent 选项。`read_image_region` 只接受调用会话已经引用的完整附件 ID,因此可以裁剪没有文件路径的用户上传图片,但不能越过会话范围。 ## 配置 @@ -33,12 +33,13 @@ await ctx.plugin(ToolFs) // this package — re |---|---|---| | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | | `read_image` | `file_path` | 通过有界字节 seam 读取 PNG/JPEG/WebP/GIF 文件,经 `ctx.attachments.saveImage` 持久保存,并在小型元数据信封旁返回图像块。只有确切路由的模型声明图像输入时才会成功。 | +| `read_image_region` | `attachment_id`、`preview_width`、`preview_height`、`x`、`y`、`width`、`height` | 解析会话有权访问的图片,把预览坐标矩形映射到存储主版本,持久保存裁剪结果并返回新图片块。 | | `write` | `file_path`、`content` | 创建文件或完整替换文件。有策略插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | | `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有策略插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`(source 两个字段仅在附件存储的规范编码缩小了该文件时出现,此时信封会写明换算回原图的坐标倍率),`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 +结构化成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`,`read_image_region` → `{ sourceAttachmentId, preview, crop, image }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。图片 source 字段只在主版本准备缩小了提交光栅时出现。原生渲染器会保留下方带行号的读取结果和变更确认。`write` 和 `edit` 从这些值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;仅用于执行的结构化值不会添加到 `tool/result`,图片渲染器则会发出由结果记录的持久图片块。 ## 工具就是执行器;策略是事件门禁 @@ -46,6 +47,7 @@ await ctx.plugin(ToolFs) // this package — re - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) - **read_image**:在任何 I/O 之前校验参数、扩展名、附件可用性、部署接受的媒体类型和图像路由;随后一次 `ctx.fs.stat`(目标缺失时与 `read` 一样记录 `absent` 观察)、以 `imageLimits.maxImageBytes` 与 `imageLimits.maxMessageImageBytes` 中较小者为上限的有界 `ctx.fs.readBytes`(结果是携带一张图像的一条消息)、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。(1 次 stat。) +- **read_image_region**:只从当前会话消息解析完整附件 ID,校验整数预览坐标,通过 `attachments.cropImage` 把矩形映射到存储主版本,并把持久裁剪结果作为图片块返回。它不执行文件系统路径操作,也不发出 `fs/observed` 事件。 - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) - **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -99,7 +101,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -模型会看到已生成的 [`read`、`read_image`、`write` 和 `edit` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-fs),参数使用 snake_case。`read_image` 只在持久附件存储已挂载时出现;schema 本身与路由无关,严格门禁在执行时拒绝。作用域工具限制可以为某个 agent 移除任一定义。 +模型会看到已生成的 [`read`、`read_image`、`read_image_region`、`write` 和 `edit` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-fs),参数使用 snake_case。图片工具只在持久附件存储已挂载时出现;schema 本身与路由无关,严格门禁在执行时拒绝。作用域工具限制可以为某个 agent 移除任一定义。 #### Token 影响 @@ -127,7 +129,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -成功的 `read_image` 返回 ``、`image` 和写明媒体类型、尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。会话日志只存储持久的 `sha256:` 附件引用;路由到的提供方在每次请求时重新读取并校验字节摘要。 +成功的 `read_image` 返回 ``、`image` 和写明媒体类型、主版本尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。成功的 `read_image_region` 返回 `image-region` 信封,写明源附件、提交的预览尺寸和矩形及结果尺寸,随后是作为原生图像块的裁剪结果。新持久引用会随结果写入会话日志,然后才进入下一次模型请求。请求适配器从主版本派生预览,因此之后的局部读取不会从已经缩小的预览继续裁剪。 #### Token 影响 @@ -155,7 +157,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。局部读取会在改变存储前拒绝空白或超出会话范围的附件 ID 以及无效预览矩形。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 @@ -169,7 +171,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 - **`read` 只处理 UTF-8 文本文件**:图像使用独立的、按扩展名路由的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。 -- **路由门禁与并发模型切换存在竞态**:`read_image` 在执行时检查最新路由的模型;在该检查与下一次请求之间提交的切换,可能让图像块落在拒绝图像内容的路由上。Web 宿主已拒绝把含图像的会话切到纯文本模型;其他前端拥有各自的等价防护。 - **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。 - **工具结果卡片没有内嵌图像预览**:UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理。 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.zh.md#no-timeouts-on-file-io))。 diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index cde24a2a35..4766bea6ba 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -1,20 +1,19 @@ /** - * The model-facing `read_image` tool: reads a PNG/JPEG/WebP/GIF file, durably - * commits its bytes through the attachment service (the same lifecycle as a - * user-uploaded image), and returns an image block so the image enters model - * context from the next request onward. + * The model-facing image tools: `read_image` commits a PNG/JPEG/WebP/GIF file, + * while `read_image_region` crops a session-authorized durable attachment by + * coordinates measured on the exact preview shown to the model. * - * The route gate is deliberately stricter than the host upload preflight: a - * tool result enters durable session history, so emitting an image on a route - * that cannot carry it would break that route's continuation. Unknown - * capability therefore refuses instead of relying on the adapter guard. + * The route gate is deliberately stricter than the host upload preflight. An + * image-reading tool is useful only when the exact calling route can inspect + * its result, so unknown capability refuses instead of relying on an adapter + * failure after filesystem and attachment work. * @module @deepseek-ai/dsh-tool-fs/src/read-image */ import { basename, extname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageMediaType, PreviewImageCrop } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -30,7 +29,7 @@ const IMAGE_EXTENSIONS: Readonly> = { '.gif': 'image/gif', } -/** The canonical outcome declared by the `read_image` output schema. */ +/** The structured outcome declared by the `read_image` output schema. */ export interface ImageReadValue { path: string image: { @@ -47,6 +46,14 @@ export interface ImageReadValue { } } +/** Structured result of cropping a session-authorized image attachment. */ +export interface ImageRegionReadValue { + sourceAttachmentId: string + preview: { width: number; height: number } + crop: { x: number; y: number; width: number; height: number } + image: ImageReadValue['image'] +} + /** * Map a model-supplied path to its declared image media type by extension. * @param filePath - the raw `file_path` argument (not yet resolved). @@ -79,9 +86,9 @@ export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, } /** - * Re-brand a canonical image outcome into the durable attachment reference an + * Re-brand a structured image outcome into the durable attachment reference an * `ImageBlock` carries. - * @param image - the canonical image metadata from the output schema. + * @param image - the image metadata from the output schema. * @returns the branded attachment reference. */ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachmentRef { @@ -92,15 +99,66 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme width: image.width, height: image.height, ...image.name === undefined ? {} : { name: image.name }, + ...image.sourceWidth === undefined ? {} : { sourceWidth: image.sourceWidth }, + ...image.sourceHeight === undefined ? {} : { sourceHeight: image.sourceHeight }, } } +function findImageRef( + content: readonly ContentBlock[], + attachmentId: string, +): ImageAttachmentRef | undefined { + for (const block of content) { + if (block.type === 'image' && block.attachment.attachmentId === attachmentId) return block.attachment + if (block.type === 'tool-result') { + const nested = findImageRef(block.content, attachmentId) + if (nested !== undefined) return nested + } + } + return undefined +} + +function sessionImageRef(exec: ToolExecution, attachmentId: string): ImageAttachmentRef { + const session = exec.agent?.session + if (session === undefined) { + throw new Error('read_image_region requires an active agent session') + } + for (const message of session.deriveMessages()) { + const ref = findImageRef(message.content, attachmentId) + if (ref !== undefined) return ref + } + throw new Error(`attachment "${attachmentId}" is not referenced by the current session`) +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`) + return value +} + +function nonNegativeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`) + return value +} + +function regionReadContent(value: ImageRegionReadValue): ContentBlock[] { + return [ + { + type: 'text', + text: `${value.sourceAttachmentId}\nimage-region\n\n` + + `preview ${value.preview.width}x${value.preview.height} px; crop ` + + `x=${value.crop.x}, y=${value.crop.y}, width=${value.crop.width}, height=${value.crop.height}; ` + + `result ${value.image.width}x${value.image.height} px\n`, + }, + { type: 'image', attachment: imageRefFromValue(value.image) }, + ] +} + /** * Format an image read as the model-facing envelope beside its image block. * A downscaled read names the on-disk dimensions and the multiplier that maps * coordinates measured on the attached image back onto the original file. * @param displayPath - the backend-resolved path rendered in the envelope's `` element. - * @param image - the canonical image metadata to summarize. + * @param image - the image metadata to summarize. * @returns the model-facing envelope; the image itself rides the adjacent image block. */ export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string { @@ -123,8 +181,8 @@ ${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} byte } /** - * Project one canonical image read into its model-facing envelope and image. - * @param value - the canonical image-read outcome. + * Project one structured image read into its model-facing envelope and image. + * @param value - the image-read outcome. * @returns the two content blocks used by native and nested dispatches. */ function imageReadContent(value: ImageReadValue): ContentBlock[] { @@ -233,6 +291,12 @@ export function applyReadImageTool(ctx: Context): void { { cause: error }, ) } + if (error.code === 'ATTACHMENT_WRITE_FAILED' && /16-bit PNG/iu.test(error.message)) { + throw new Error( + `cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, + { cause: error }, + ) + } if (error.code !== 'IMAGE_TYPE_MISMATCH') throw error const extension = extname(target.displayPath).toLowerCase() throw new Error( @@ -267,4 +331,101 @@ export function applyReadImageTool(ctx: Context): void { } }, })) + + ctx.tools.register(defineTool({ + name: 'read_image_region', + description: 'Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image.', + parameters: { + attachment_id: { type: 'string', required: true, description: 'Complete attachment id shown beside the image.' }, + preview_width: { type: 'integer', required: true, description: 'Width of the preview shown to the model.' }, + preview_height: { type: 'integer', required: true, description: 'Height of the preview shown to the model.' }, + x: { type: 'integer', required: true, description: 'Left edge in preview pixels.' }, + y: { type: 'integer', required: true, description: 'Top edge in preview pixels.' }, + width: { type: 'integer', required: true, description: 'Crop width in preview pixels.' }, + height: { type: 'integer', required: true, description: 'Crop height in preview pixels.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + sourceAttachmentId: { type: 'string', required: true }, + preview: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + }, + }, + crop: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + x: { type: 'integer', required: true }, + y: { type: 'integer', required: true }, + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + }, + }, + image: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + attachmentId: { type: 'string', required: true }, + mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true }, + bytes: { type: 'integer', required: true }, + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + name: { type: 'string' }, + sourceWidth: { type: 'integer' }, + sourceHeight: { type: 'integer' }, + }, + }, + }, + }, + render: (_args, value) => regionReadContent(value), + }, + isConcurrencySafe: () => true, + async execute(args, exec) { + const attachmentId = args.attachment_id.trim() + if (attachmentId.length === 0) throw new Error('attachment_id must be a non-empty string') + const ref = sessionImageRef(exec, attachmentId) + await assertImageCapableRoute(ctx, exec, attachmentId) + const crop: PreviewImageCrop = { + previewWidth: positiveInteger(args.preview_width, 'preview_width'), + previewHeight: positiveInteger(args.preview_height, 'preview_height'), + x: nonNegativeInteger(args.x, 'x'), + y: nonNegativeInteger(args.y, 'y'), + width: positiveInteger(args.width, 'width'), + height: positiveInteger(args.height, 'height'), + } + const saved = await ctx.attachments.cropImage(ref, crop, exec.signal) + return { + sourceAttachmentId: ref.attachmentId, + preview: { width: crop.previewWidth, height: crop.previewHeight }, + crop: { x: crop.x, y: crop.y, width: crop.width, height: crop.height }, + image: { + attachmentId: saved.ref.attachmentId, + mediaType: saved.ref.mediaType, + bytes: saved.ref.bytes, + width: saved.ref.width, + height: saved.ref.height, + ...saved.ref.name === undefined ? {} : { name: saved.ref.name }, + ...saved.ref.sourceWidth === undefined ? {} : { sourceWidth: saved.ref.sourceWidth }, + ...saved.ref.sourceHeight === undefined ? {} : { sourceHeight: saved.ref.sourceHeight }, + }, + } + }, + presentCall(args): GenericCallView { + return { + card: 'generic', + title: `Read image region ${args.attachment_id}`, + kind: 'read', + } + }, + })) } diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index dcc6ab7d5e..2f67a464fd 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -12,8 +12,8 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import { CallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, Message, StreamChunk } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import type { Config as ToolConfig } from '@deepseek-ai/dsh-tools' @@ -122,12 +122,13 @@ async function setup(options: SetupOptions = {}) { } /** A fake calling agent pinned to one routed provider/model. */ -function agentOn(model: string | undefined, provider = 'visual'): object { +function agentOn(model: string | undefined, provider = 'visual', messages: readonly Message[] = []): object { return { options: {}, session: { header: { cwd: dir }, requestHeader: () => (model === undefined ? undefined : { config: { provider, model } }), + deriveMessages: () => [...messages], append: () => undefined, }, } @@ -169,6 +170,61 @@ describe('imageRefFromValue', () => { const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } expect(imageRefFromValue(base)).toEqual(base) expect(imageRefFromValue({ ...base, name: 'a.png' })).toEqual({ ...base, name: 'a.png' }) + expect(imageRefFromValue({ ...base, sourceWidth: 4, sourceHeight: 2 })) + .toEqual({ ...base, sourceWidth: 4, sourceHeight: 2 }) + }) +}) + +describe('read_image_region', () => { + it('crops a session-visible attachment and returns a new logged image reference', async () => { + const ctx = await setup() + const attachments = ctx.attachments + const source = await attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png', name: 'grid.png' }) + const history = [createUserMessage({ + content: [{ type: 'image', attachment: source.ref }], + source: { kind: 'plugin', plugin: 'test' }, + })] + + const result = await call(ctx, 'read_image_region', { + attachment_id: source.ref.attachmentId, + preview_width: 3, + preview_height: 3, + x: 1, + y: 0, + width: 2, + height: 2, + }, agentOn('vision-model', 'visual', history)) + + expect(result.isError).toBe(false) + expect(result.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('crop x=1, y=0, width=2, height=2') as string, + }) + expect(result.content[1]).toMatchObject({ + type: 'image', + attachment: { width: 2, height: 2, name: 'grid-crop.png' }, + }) + const cropped = result.content[1] + if (cropped?.type !== 'image') throw new Error('expected cropped image block') + await expect(attachments.readImage(cropped.attachment)).resolves.toMatchObject({ + ref: { attachmentId: cropped.attachment.attachmentId }, + }) + }) + + it('refuses an attachment that is absent from the current session', async () => { + const ctx = await setup() + const result = await call(ctx, 'read_image_region', { + attachment_id: `sha256:${'f'.repeat(64)}`, + preview_width: 800, + preview_height: 800, + x: 0, + y: 0, + width: 100, + height: 100, + }, agentOn('vision-model')) + + expect(result.isError).toBe(true) + expect(text(result)).toContain('not referenced by the current session') }) }) @@ -438,6 +494,15 @@ describe('image admission failures', () => { expect(storageFault.isError).toBe(true) expect(text(storageFault)).toContain('Unable to persist image attachment.') + FailingStore.failure = new AttachmentError( + 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.', + 'ATTACHMENT_WRITE_FAILED', + ) + const sixteenBit = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(text(sixteenBit)).toContain( + `cannot read "${join(dir, 'red.png')}": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, + ) + FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE') const overBudget = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(overBudget.isError).toBe(true) @@ -501,7 +566,7 @@ describe('image admission failures', () => { }) it('names the on-disk dimensions and coordinate multiplier when storage downscales', async () => { - /** Store whose canonical encoding halves the source on both sides. */ + /** Store whose image master halves the source on both sides. */ class DownscalingStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = Object.freeze({ maxImageBytes: 1024, @@ -553,7 +618,7 @@ describe('registration surface', () => { const attachmentsFiber = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) const toolFsFiber = await ctx.plugin(ToolFs) const names = () => ctx.tools.schemas().map(schema => schema.name).sort() - expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) + expect(names()).toEqual(['edit', 'read', 'read_image', 'read_image_region', 'write']) // Disposing only the attachment store tears down the scoped inject fiber: // read_image withdraws while the unconditional tools stay registered. @@ -562,7 +627,7 @@ describe('registration surface', () => { // Remounting the store restores the conditional registration. const remounted = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) - expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) + expect(names()).toEqual(['edit', 'read', 'read_image', 'read_image_region', 'write']) // Disposing the whole plugin withdraws every tool, read_image included. await toolFsFiber.dispose() @@ -581,6 +646,12 @@ describe('registration surface', () => { kind: 'read', locations: [{ path: 'shot.png' }], }) + expect(ctx.tools.executionMode({ + signal: testToolSignal, + callId: CallId('region-parallel'), + name: 'read_image_region', + arguments: { attachment_id: 'sha256:a', preview_width: 1, preview_height: 1, x: 0, y: 0, width: 1, height: 1 }, + })).toEqual({ kind: 'parallel' }) }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e708353c00..dd1268fe00 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,7 +14,7 @@ import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatu import type {} from '@deepseek-ai/dsh-agent-presets/types' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' @@ -186,10 +186,6 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b } /** True when the current model-visible surface contains an image. */ -function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean { - return messages.some(message => contentHasImage(message.content)) -} - /** Resolve the first reference matching one opaque id. */ function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { for (const event of events) { @@ -2221,18 +2217,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ? {} : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, }) - const pendingImage = [...found.agent.inbox.nextTurn, ...found.agent.inbox.nextStep] - .some(message => contentHasImage(message.content)) - if (pendingImage || messagesHaveImage(found.agent.session.deriveMessages())) { - const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model) - if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { - return err(request, { - code: 'model-unavailable', - message: `Model "${resolved.model}" does not accept image input, but this session already contains images; select an image-capable model.`, - details: { provider, model }, - }) - } - } const selected: ModelSelection = { provider: resolved.provider, model: resolved.model, diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 55cb15ca9f..99f99c3432 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -156,12 +156,7 @@ describe('Web session model selection', () => { validateImage, saveImage, } - ctx.provide('attachments', { - ...attachments, - saveImages(inputs: readonly Parameters[0][]) { - return AttachmentStore.prototype.saveImages.call(attachments, inputs) - }, - } as never) + ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) const api = createApiProxy(ctx, { @@ -207,7 +202,7 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('refuses a text-only selection while durable or pending image content remains visible', async () => { + it('allows a text-only selection while durable or pending images remain available for later models', async () => { const { ctx, agent, sessionId } = await harness() registerTextOnly(ctx) const api = createApiProxy(ctx, { @@ -221,9 +216,9 @@ describe('Web session model selection', () => { agent.session.append('user/message', { id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image], } as never, { surfaceOp: 'append' }) - expect((await api.sessions.selectModel(request({ + expect(expectValue(await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', - }))).result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) + }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) agent.session.append('user/message', { id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' }, @@ -235,10 +230,6 @@ describe('Web session model selection', () => { ;(agent.inbox.nextTurn as UserMessage[]).push({ id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image], } as never) - expect((await api.sessions.selectModel(request({ - sessionId, provider: 'text-only', model: 'plain', - }))).result.ok).toBe(false) - ;(agent.inbox.nextTurn as UserMessage[]).length = 0 expect(expectValue(await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 82da16e4f0..c5aa0c7e8c 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: bae9011135a9cbc14467086e4b6ebc6f052ed230 -README.zh.md: 0a5f0224dbebd62766775822260585825579f4a7 +README.md: da2044abe6f5201c1bed1ca6b529b34c34282ea8 +README.zh.md: d17d7a739640c31e9e88f154a11d5e24011e54f7 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index bae9011135..da2044abe6 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,7 +20,12 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default + maxRequestFilesBytes: 134217728 # optional positive integer; 128 MiB raw request-image default + maxImagesPerRequest: 600 # provider request image-count limit + imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps + fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days + fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining + fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -34,16 +39,22 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire - id: deepseek-v4-flash-vision-exp name: DeepSeek-V4-Flash-Vision-Exp inputModalities: [text, image] + imagePixelBudget: 640000 + imageMaxBytes: 1048576 - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry may declare `inputModalities: [text, image]`. The adapter resolves user and tool-result `ImageBlock` references through `ctx.attachments`, verifies the stored bytes, and sends transient `data:;base64,...` `image_url` parts without changing the durable session message. Text-only and unlisted models reject image input before credential, attachment, or network I/O. System and assistant history remain image-free; tool-result images follow their string-only `tool` messages in a separate `user` message. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 master becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id, actual request dimensions, and the preview-coordinate arguments for `read_image_region`. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. -`maxRequestImageBytes` bounds accumulated base64 image payload and defaults to 20 MiB, leaving headroom below the official 30 MiB request-body limit for text, tools, and JSON framing. When history exceeds the bound, the oldest images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]` until the request fits; omitted attachments are not read. Attachment admission continues to own per-image and per-message raw-byte, media, dimension, and pixel limits. +`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. + +Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, crop, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports an expired, deleted, missing, or invalid file id and names a used id, the adapter removes only that mapping. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. + +One quota upload failure triggers deletion of the configured number of oldest `dsh-` files and one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -53,11 +64,11 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `low`, `high`, or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter normally makes one chat request per `stream()` call and makes a second only for the stale-file recovery described above. It registers the configured retry policy as provider metadata, and `dsh-llm-retry` separately executes that policy at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) -Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, image bound, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Three optional seams feed that thunk: +Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, image and Files policies, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Three optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. @@ -84,7 +95,7 @@ DeepSeek request identity is separate from app attribution. After credential res ## 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 and 413), `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. Attachment reads retain their stable attachment failure code rather than becoming transport failures. 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). +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 and 413), `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. If DeepSeek rejects a normalized image, the primary message names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. With several candidates and no file id in the provider detail, it lists each possible image instead of assigning the failure to the first one. The raw response remains the error `cause`; it is never the only user-visible diagnostic. Attachment reads retain their stable attachment failure code rather than becoming transport failures. 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). ## Model Experience @@ -92,7 +103,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config. The vision model receives retained user and tool-result images as Files API references beside stable attachment handles and preview dimensions; an over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect @@ -122,4 +133,4 @@ Loop-retained response blocks append to the next request and preserve its earlie - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. -- **Images are input-only durable attachments** — direct external URLs, the Files API, and assistant image output are not supported. +- **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input uses the Files API. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 0a5f0224db..d17d7a7396 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -20,7 +20,12 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default - maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default + maxRequestFilesBytes: 134217728 # optional positive integer; 128 MiB raw request-image default + maxImagesPerRequest: 600 # provider request image-count limit + imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps + fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days + fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining + fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -34,16 +39,22 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: - id: deepseek-v4-flash-vision-exp name: DeepSeek-V4-Flash-Vision-Exp inputModalities: [text, image] + imagePixelBudget: 640000 + imageMaxBytes: 1048576 - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项可以声明 `inputModalities: [text, image]`。适配器通过 `ctx.attachments` 解析 user 和工具结果中的 `ImageBlock` 引用,校验已存储字节,再发送瞬态 `data:;base64,...` `image_url` 部分,不改变持久会话消息。纯文本模型与未列出模型会在凭据、附件或网络 I/O 前拒绝图片输入。System 和 assistant 历史仍不能包含图片;工具结果图片会在仅含字符串的 `tool` 消息后,通过单独的 `user` 消息发送。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 主版本会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及 `read_image_region` 所需的预览坐标参数。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 -`maxRequestImageBytes` 限制累计 base64 图片 payload,默认值为 20 MiB,为官方 30 MiB 请求正文限制中的文本、工具和 JSON 分帧保留余量。历史超过上限时,适配器会从最旧图片开始替换为固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`,直至请求可容纳;被省略的附件不会被读取。附件准入仍负责单图和单消息原始字节数、媒体类型、尺寸与像素限制。 +`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 + +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算、裁剪区域及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的某个 ID,适配器只删除该映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 + +一次上传配额错误会触发删除配置数量的最旧 `dsh-` 文件,然后重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -53,11 +64,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `low`、`high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器通常每次 `stream()` 调用发起一次 chat 请求,只有上述失效文件恢复会发起第二次。适配器把已配置重试策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) -连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值、图片上限与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。三个可选 seam 供给该 thunk: +连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值、图片和 Files 策略与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。三个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 @@ -84,7 +95,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 ## 错误 -非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 +非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。如果 DeepSeek 拒绝一张已规范化图片,主错误会写明附件 ID 或显示名称、持久消息和图片位置、规范化后的媒体类型、8-bit sRGB/sRGBA 位深、尺寸和提供方消息。存在多张候选图片且提供方详细信息没有 file id 时,错误会列出全部可能图片,不会把错误归给第一张。原始响应保留为错误 `cause`,不会成为唯一的用户可见诊断。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 ## 模型体验 @@ -92,7 +103,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置。视觉模型会通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和预览尺寸;超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 @@ -122,4 +133,4 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **会跳过插件添加的内容块类型**:核心文本与支持的图片块会被序列化,空工具输出会以字面 `(no output)` 通过协议发送。 -- **图片是仅输入的持久附件**:不支持直接外部 URL、Files API 和 assistant 图片输出。 +- **图片是仅输入的持久附件**:不支持直接外部 URL 和 assistant 图片输出;DeepSeek 图片输入使用 Files API。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 1c7e8aadf0..effb77d4c0 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -33,10 +33,13 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", @@ -48,10 +51,13 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 638d555b1e..8d9381c67f 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -10,20 +10,31 @@ import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { + ContentBlock, GenerateOptions, LlmModelInfo, LlmProviderInfo, + PreparedAdapterCall, LlmResolvedModelInfo, ModelModality, ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentId, + AttachmentStore, + ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, +} from '@deepseek-ai/dsh-attachment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { serializeRequest, serializeRequestWithImages } from './serialize.ts' -import type { RequestDefaults } from './serialize.ts' +import type { ImageWireLocation, RequestDefaults } from './serialize.ts' +import { DeepSeekFileStore } from './file-store.ts' +import type { DeepSeekFilePolicy } from './file-store.ts' +import type { DeepSeekFileId } from './file-id.ts' import { parseSse } from './sse.ts' import { translate } from './translate.ts' import type { WireError } from './types.ts' @@ -42,6 +53,12 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] + /** Total-pixel budget for one deterministic request preview. */ + imagePixelBudget?: number + /** Encoded-byte cap for one deterministic request preview. */ + imageMaxBytes?: number + /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ + imageDetail?: 'auto' | 'low' } /** @@ -70,8 +87,16 @@ export interface DeepSeekConnectionOptions { models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number - /** Maximum accumulated base64 image payload in one request. */ - maxRequestImageBytes: number + /** Maximum accumulated file-referenced image bytes in one request. */ + maxRequestFilesBytes: number + /** Maximum number of file-referenced images in one request. */ + maxImagesPerRequest: number + /** Raw-byte removal step after the file-reference bound is exceeded. */ + imageOffloadByteQuantum: number + /** Image-count removal step after the count bound is exceeded. */ + imageOffloadCountQuantum: number + /** Upload expiry, refresh, and quota-recovery policy. */ + filePolicy: DeepSeekFilePolicy /** Provider-owned model-request retry policy, already resolved. */ retryPolicy: ResolvedRetryPolicy } @@ -91,6 +116,8 @@ export interface DeepSeekAdapterOptions { resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ resolveAttachments?: () => AttachmentStore | undefined + /** Resolve the process-wide upload reuse store. */ + resolveFiles?: () => DeepSeekFileStore } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -99,8 +126,26 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 export const DEFAULT_CONTEXT_WINDOW = 1_000_000 /** Default per-request output-token cap. */ export const DEFAULT_MAX_TOKENS = 256_000 -/** Default bound on accumulated base64 image payload per request. */ -export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 +/** Default bound on accumulated file-referenced image bytes per request. */ +export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024 +/** Provider request image-count limit. */ +export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600 +/** Total-pixel budget matching DeepSeek's normal vision projection. */ +export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000 +/** Total-pixel budget matching provider low-detail image input. */ +export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512 +/** Encoded-byte cap for one deterministic model-request image. */ +export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 +/** Deterministic raw-byte removal step. */ +export const DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM = 64 * 1024 * 1024 +/** Deterministic image-count removal step. */ +export const DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM = 20 +/** Default explicit lifetime for uploaded images. */ +export const DEFAULT_FILE_EXPIRY_SECONDS = 7 * 24 * 60 * 60 +/** Default proactive refresh window for indexed file ids. */ +export const DEFAULT_FILE_REFRESH_MARGIN_SECONDS = 60 * 60 +/** Default number of oldest harness-owned files removed on quota recovery. */ +export const DEFAULT_FILE_QUOTA_CLEANUP_BATCH = 100 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' const OFF_REASONING_EFFORT = ReasoningEffortId('off') const LOW_REASONING_EFFORT = ReasoningEffortId('low') @@ -116,6 +161,112 @@ const OFF_ONLY_REASONING_EFFORTS = [ { id: OFF_REASONING_EFFORT, name: 'Off' }, ] as const +function collectImageRefs( + content: readonly ContentBlock[], + refs: Map, +): void { + for (const block of content) { + if (block.type === 'image') refs.set(block.attachment.attachmentId, block.attachment) + else if (block.type === 'tool-result') collectImageRefs(block.content, refs) + } +} + +function requestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { + return { + maxPixels: model.imagePixelBudget + ?? (model.imageDetail === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET), + maxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, + } +} + +async function prepareRequestImages( + options: GenerateOptions, + attachments: AttachmentStore, + model: DeepSeekCatalogModel, + signal: AbortSignal, +): Promise> { + const refs = new Map() + for (const message of options.messages) collectImageRefs(message.content, refs) + const policy = requestImagePolicy(model) + const orderedRefs = [...refs.values()] + const projected = await attachments.readImageRequests(orderedRefs, policy, signal) + return new Map(orderedRefs.map((ref, index) => ( + [ref.attachmentId, projected[index] as RequestImageAttachment] + ))) +} + +function providerRejectedNormalizedImage(detail: string): boolean { + const reasonBeforeImage = /(?:unsupported|invalid|cannot read|failed to (?:decode|process)).{0,40}image/iu + const imageBeforeReason = /image.{0,40}(?:unsupported|invalid|cannot be decoded)/iu + return reasonBeforeImage.test(detail) || imageBeforeReason.test(detail) +} + +interface UsedRequestFile { + version: RequestImageAttachment + fileId: DeepSeekFileId + location: ImageWireLocation +} + +function providerRejectedFileId(detail: string): boolean { + const file = /\bfile(?:[_ -]?(?:id|api|not[_ -]?found|deleted|expired))?/iu.test(detail) + const missing = /(?:expired|not[_ -]?found|deleted|does not exist)/iu.test(detail) + const invalidId = /(?:invalid.{0,20}file[_ -]?(?:id|api)|file[_ -]?(?:id|api).{0,20}invalid)/iu.test(detail) + return file && (missing || invalidId) +} + +function detailNamesFileId(detail: string, fileId: DeepSeekFileId): boolean { + let index = detail.indexOf(fileId) + while (index >= 0) { + const before = detail[index - 1] + const after = detail[index + fileId.length] + if ((before === undefined || !/[\p{L}\p{N}_-]/u.test(before)) + && (after === undefined || !/[\p{L}\p{N}_-]/u.test(after))) return true + index = detail.indexOf(fileId, index + 1) + } + return false +} + +function staleMappings( + files: readonly UsedRequestFile[], + detail: string, +): UsedRequestFile[] { + const unique = [...new Map(files.map(file => [`${file.version.variantId}\0${file.fileId}`, file])).values()] + const exact = unique.filter(file => detailNamesFileId(detail, file.fileId)) + return exact.length > 0 ? exact : unique +} + +function normalizedImageFacts( + file: { version: RequestImageAttachment; location: ImageWireLocation }, +): string { + const version = file.version + const name = version.master.name ?? version.master.attachmentId + const colour = version.hasAlpha ? 'sRGBA' : 'sRGB' + return `"${name}" at message ${file.location.message}, image ${file.location.image} ` + + `(${version.mediaType}, 8-bit ${colour}, ${version.width}x${version.height})` +} + +function normalizedImageDiagnostic( + files: readonly UsedRequestFile[], + providerMessage: string, + providerDetail: string, +): string { + const exact = files.find(file => detailNamesFileId(providerDetail, file.fileId)) + const target = exact ?? (files.length === 1 ? files[0] : undefined) + if (target !== undefined) { + return `DeepSeek rejected normalized image ${normalizedImageFacts(target)}: ${providerMessage}. ` + + 'The provider rejected bytes already normalized by the harness; PNG, JPEG, WebP, and GIF remain supported input formats.' + } + const candidates = [...new Map(files.map(file => [ + `${file.version.variantId}\0${file.location.message}\0${file.location.image}`, + file, + ])).values()] + return `DeepSeek rejected a normalized request image: ${providerMessage}. Candidate images: ` + + `${candidates.map(normalizedImageFacts).join('; ')}. ` + + 'The provider rejected bytes already normalized by the harness; PNG, JPEG, WebP, and GIF remain supported input formats.' +} + function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo { return { provider, @@ -169,8 +320,11 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. */ export class DeepSeekAdapter extends LlmAdapter { + private readonly files: DeepSeekFileStore + constructor(private readonly config: DeepSeekAdapterOptions) { super() + this.files = config.resolveFiles?.() ?? new DeepSeekFileStore() } override providerInfo(provider: string): LlmProviderInfo { @@ -190,11 +344,18 @@ export class DeepSeekAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const connection = this.config.options() + return Promise.resolve(this.modelInfoFor(this.config.options(), provider, model)) + } + + private modelInfoFor( + connection: DeepSeekConnectionOptions, + provider: string, + model: string, + ): LlmResolvedModelInfo { const configured = connection.models.find(entry => entry.id === model) const contextWindow = configured?.contextWindow ?? connection.defaultContextWindow - return Promise.resolve({ + return { // An uncatalogued endpoint is safely treated as text-only. Declaring an // unverified image capability would let the host persist input that the // endpoint may reject on every later turn. @@ -222,16 +383,30 @@ export class DeepSeekAdapter extends LlmAdapter { : HIGH_REASONING_EFFORT, }, }, + } + } + + override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { + const connection = this.config.options() + return Promise.resolve({ + model: this.modelInfoFor(connection, provider, model), + stream: options => this.streamWithConnection(options, connection), }) } - async * stream(options: GenerateOptions): AsyncIterable { + stream(options: GenerateOptions): AsyncIterable { + return this.streamWithConnection(options, this.config.options()) + } + + private async * streamWithConnection( + options: GenerateOptions, + connection: DeepSeekConnectionOptions, + ): AsyncIterable { // One resolution per stream call: connection facts and the credential // freeze here and hold for this whole request, so an in-flight stream // never observes a configuration change and the next call re-resolves. // The key resolves *from this snapshot*, so an endpoint and the secret // sent to it can never come from different configuration generations. - const connection = this.config.options() const hasImages = options.messages.some(message => contentHasImage(message.content)) let attachments: AttachmentStore | undefined if (hasImages) { @@ -310,16 +485,6 @@ export class DeepSeekAdapter extends LlmAdapter { attachments: AttachmentStore | undefined, onComment: () => void, ): AsyncIterable { - const body = attachments === undefined - ? serializeRequest(options, connection.defaults) - : await serializeRequestWithImages(options, { - attachments, - maxRequestImageBytes: connection.maxRequestImageBytes, - signal, - }, connection.defaults) - // Prepared outside the try so the TRANSPORT label below covers exactly the - // transport boundary, never a serialization failure. - const payload = JSON.stringify(body) const headers = { 'authorization': `Bearer ${apiKey}`, 'content-type': 'application/json', @@ -334,53 +499,92 @@ export class DeepSeekAdapter extends LlmAdapter { : {}, } - // TODO(http): adopt the Cordis HTTP service when shared transport configuration - // outweighs its additional runtime dependencies. - let response: Response - try { - response = await fetch(`${connection.baseURL}/chat/completions`, { - method: 'POST', - headers, - body: payload, - signal, - }) - } catch (error: unknown) { - // The outer stream distinguishes caller cancellation and watchdog expiry. - if (signal.aborted) throw error - // fetch wraps every transport failure (DNS, refused connection, TLS, - // proxy) in a bare `TypeError: fetch failed` whose actionable detail - // lives on `cause`. Wrapping with the endpoint and chaining the cause - // lets `errorChain` render the full diagnosis at every reporting boundary. - throw new LlmError( - `DeepSeek API request to ${connection.baseURL} failed`, - 'TRANSPORT', - { cause: error }, - ) - } + const fileConnection = { baseURL: connection.baseURL, apiKey } + const model = connection.models.find(entry => entry.id === options.model) + const requestImages = attachments === undefined || model === undefined + ? new Map() + : await prepareRequestImages(options, attachments, model, signal) + for (let fileAttempt = 0; fileAttempt < 2; fileAttempt += 1) { + const usedFiles: UsedRequestFile[] = [] + const body = attachments === undefined + ? serializeRequest(options, connection.defaults) + : await serializeRequestWithImages(options, { + requestImages, + resolveFileId: async (version, _block, location) => { + const resolved = await this.files.ensureUploaded( + version, + fileConnection, + connection.filePolicy, + signal, + ) + usedFiles.push({ version, fileId: resolved.record.fileId, location }) + return resolved.record.fileId + }, + maxRequestFilesBytes: connection.maxRequestFilesBytes, + maxImagesPerRequest: connection.maxImagesPerRequest, + byteQuantum: connection.imageOffloadByteQuantum, + countQuantum: connection.imageOffloadCountQuantum, + }, connection.defaults) + const payload = JSON.stringify(body) - if (!response.ok) { - let message = `DeepSeek API error (HTTP ${response.status})` - let providerError: WireError['error'] + // TODO(http): adopt the Cordis HTTP service when shared transport configuration + // outweighs its additional runtime dependencies. + let response: Response try { - const parsed = await response.json() as WireError - providerError = parsed.error - if (providerError?.message) message = providerError.message - } catch { - // Only swallow error-body parsing: the HTTP status still identifies the - // failure, so malformed gateway JSON must not mask it. + response = await fetch(`${connection.baseURL}/chat/completions`, { + method: 'POST', + headers, + body: payload, + signal, + }) + } catch (error: unknown) { + if (signal.aborted) throw error + throw new LlmError( + `DeepSeek API request to ${connection.baseURL} failed`, + 'TRANSPORT', + { cause: error }, + ) } - const delay = providerRetryAfterMs(response.headers.get('retry-after')) - const id = requestId(response.headers) - throw new LlmError(message, httpErrorCode(response.status, providerError), { - status: response.status, - ...delay === undefined ? {} : { providerRetryAfterMs: delay }, - ...id === undefined ? {} : { requestId: id }, - }) - } - if (!response.body) { - throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') - } - yield* translate(parseSse(response.body, onComment)) + if (!response.ok) { + let message = `DeepSeek API error (HTTP ${response.status})` + let providerError: WireError['error'] + const rawResponse = await response.text() + try { + const parsed = JSON.parse(rawResponse) as WireError + providerError = parsed.error + if (providerError?.message) message = providerError.message + } catch { + // The HTTP status remains authoritative when a gateway returns malformed JSON. + } + const detail = [providerError?.code, providerError?.type, providerError?.message] + .filter((field): field is string => typeof field === 'string') + .join(' ') + const staleFile = usedFiles.length > 0 && providerRejectedFileId(detail) + if (staleFile) { + await Promise.all(staleMappings(usedFiles, detail).map(file => ( + this.files.invalidate(file.version, file.fileId, fileConnection) + ))) + if (fileAttempt === 0) continue + } + if (response.status === 400 && usedFiles.length > 0 && providerRejectedNormalizedImage(detail)) { + message = normalizedImageDiagnostic(usedFiles, message, detail) + } + const delay = providerRetryAfterMs(response.headers.get('retry-after')) + const id = requestId(response.headers) + throw new LlmError(message, httpErrorCode(response.status, providerError), { + cause: new Error(rawResponse.length > 0 ? rawResponse : `DeepSeek HTTP ${response.status}`), + status: response.status, + ...delay === undefined ? {} : { providerRetryAfterMs: delay }, + ...id === undefined ? {} : { requestId: id }, + }) + } + if (!response.body) { + throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') + } + + yield* translate(parseSse(response.body, onComment)) + return + } } } diff --git a/packages/llm/llm-deepseek/src/file-id.ts b/packages/llm/llm-deepseek/src/file-id.ts new file mode 100644 index 0000000000..fd77de372f --- /dev/null +++ b/packages/llm/llm-deepseek/src/file-id.ts @@ -0,0 +1,27 @@ +/** DeepSeek Files API identifiers. @module dsh-llm-deepseek/file-id */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque identifier returned by the DeepSeek Files API. */ +export type DeepSeekFileId = Branded<'DeepSeekFileId'> + +/** + * Brand a provider-returned file identifier after wire validation. + * @param id - non-empty Files API identifier. + * @returns the same string with its provider identity attached at type level. + */ +export function DeepSeekFileId(id: string): DeepSeekFileId { + return id as DeepSeekFileId +} + +/** Non-secret digest identifying one endpoint and API-key file namespace. */ +export type DeepSeekFileScope = Branded<'DeepSeekFileScope'> + +/** + * Brand a locally derived namespace digest. + * @param scope - SHA-256 digest of endpoint and API key. + * @returns the same string with namespace identity attached at type level. + */ +export function DeepSeekFileScope(scope: string): DeepSeekFileScope { + return scope as DeepSeekFileScope +} diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts new file mode 100644 index 0000000000..1ec943a7f9 --- /dev/null +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -0,0 +1,257 @@ +/** DeepSeek Files API upload reuse, invalidation, and quota recovery. @module dsh-llm-deepseek/file-store */ + +import type { RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { LlmError } from '@deepseek-ai/dsh-llm' +import { DeepSeekFilesClient, isFilesQuotaError } from './files-api.ts' +import type { DeepSeekFileId } from './file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from './upload-index.ts' +import type { DeepSeekUploadRecord } from './upload-index.ts' + +/** DeepSeek chat accepts at most 32 MiB per image even when it is referenced by file id. */ +export const MAX_CHAT_IMAGE_BYTES = 32 * 1024 * 1024 +const OWNED_FILE_PREFIX = 'dsh-' + +/** Resolved file-store policy from the plugin configuration. */ +export interface DeepSeekFilePolicy { + expiresAfterSeconds: number + refreshMarginSeconds: number + quotaCleanupBatch: number +} + +/** Connection facts needed by file operations. */ +export interface DeepSeekFileConnection { + baseURL: string + apiKey: string +} + +/** Result of one file-id resolution. */ +export interface DeepSeekFileReference { + record: DeepSeekUploadRecord + uploaded: boolean +} + +interface FileStoreOptions { + index?: DeepSeekUploadIndex + now?: () => number + fetch?: typeof fetch +} + +function extension(mediaType: RequestImageAttachment['mediaType']): 'png' | 'jpeg' | 'webp' | 'gif' { + switch (mediaType) { + case 'image/png': return 'png' + case 'image/jpeg': return 'jpeg' + case 'image/webp': return 'webp' + case 'image/gif': return 'gif' + } +} + +function filename(version: RequestImageAttachment): string { + const master = String(version.master.attachmentId).slice('sha256:'.length, 'sha256:'.length + 16) + const variant = String(version.variantId).slice('sha256:'.length, 'sha256:'.length + 8) + return `${OWNED_FILE_PREFIX}${master}-${variant}.${extension(version.mediaType)}` +} + +/** User-scoped durable file-id reuse for the DeepSeek route. */ +export class DeepSeekFileStore { + private readonly index: DeepSeekUploadIndex + private readonly now: () => number + private readonly fetchImpl: typeof fetch | undefined + private readonly inflight = new Map>() + + /** + * @param options - testable index, clock, and transport boundaries. + */ + constructor(options: FileStoreOptions = {}) { + this.index = options.index ?? new DeepSeekUploadIndex() + this.now = options.now ?? Date.now + this.fetchImpl = options.fetch + } + + private client(connection: DeepSeekFileConnection): DeepSeekFilesClient { + return new DeepSeekFilesClient({ + baseURL: connection.baseURL, + apiKey: connection.apiKey, + ...this.fetchImpl === undefined ? {} : { fetch: this.fetchImpl }, + }) + } + + /** + * Resolve or upload one deterministic request image. Concurrent calls in this process share one promise. + * @param version - deterministic model-request bytes and complete transformation identity. + * @param connection - endpoint and API-key snapshot. + * @param policy - expiry and quota-recovery policy. + * @param signal - request cancellation. + * @returns a reusable file id and whether this call published a new upload. + */ + ensureUploaded( + version: RequestImageAttachment, + connection: DeepSeekFileConnection, + policy: DeepSeekFilePolicy, + signal?: AbortSignal, + ): Promise { + const scope = deepSeekFileScope(connection.baseURL, connection.apiKey) + const key = `${scope}\0${version.variantId}` + const active = this.inflight.get(key) + if (active !== undefined) return active + const operation = this.ensureUploadedOnce(version, connection, policy, signal) + this.inflight.set(key, operation) + void operation.finally(() => { + if (this.inflight.get(key) === operation) this.inflight.delete(key) + }).catch(() => {}) + return operation + } + + private async ensureUploadedOnce( + version: RequestImageAttachment, + connection: DeepSeekFileConnection, + policy: DeepSeekFilePolicy, + signal?: AbortSignal, + ): Promise { + if (version.bytes > MAX_CHAT_IMAGE_BYTES) { + throw new LlmError('DeepSeek chat image exceeds the 32 MiB per-image limit.', 'INVALID_REQUEST') + } + const scope = deepSeekFileScope(connection.baseURL, connection.apiKey) + const now = this.now() + const marginMs = policy.refreshMarginSeconds * 1_000 + const cached = await this.index.get(scope, version.variantId, now, marginMs) + if (cached !== undefined) return { record: cached, uploaded: false } + + const client = this.client(connection) + const upload = async (): Promise => { + const remote = await client.upload({ + data: version.data, + mediaType: version.mediaType, + filename: filename(version), + expiresAfterSeconds: policy.expiresAfterSeconds, + ...signal === undefined ? {} : { signal }, + }) + if (remote.bytes !== version.data.byteLength || remote.expiresAt === undefined) { + throw new LlmError('DeepSeek Files API upload response does not match the submitted image.', 'INVALID_RESPONSE') + } + return { + scope, + masterAttachmentId: version.master.attachmentId, + variantId: version.variantId, + fileId: remote.id, + bytes: remote.bytes, + createdAt: remote.createdAt * 1_000, + expiresAt: remote.expiresAt * 1_000, + } + } + + let candidate: DeepSeekUploadRecord + try { + candidate = await upload() + } catch (error: unknown) { + if (!isFilesQuotaError(error)) throw error + const deleted = await this.reclaimOldestOwned(connection, policy.quotaCleanupBatch, signal) + if (deleted === 0) throw error + candidate = await upload() + } + const committed = await this.index.commit(candidate, this.now(), marginMs) + if (!committed.accepted) { + try { + await client.delete(candidate.fileId, signal) + } catch { + // The winning mapping is durable. A failed duplicate cleanup affects quota only and is retried by recovery. + } + } + return { record: committed.record, uploaded: committed.accepted } + } + + /** + * Invalidate one exact local mapping after the chat endpoint rejects its remote id. + * @param version - request-image version whose remote generation failed. + * @param fileId - exact rejected file id. + * @param connection - endpoint and API-key snapshot. + */ + async invalidate( + version: RequestImageAttachment, + fileId: DeepSeekFileId, + connection: DeepSeekFileConnection, + ): Promise { + await this.index.remove( + deepSeekFileScope(connection.baseURL, connection.apiKey), + version.variantId, + fileId, + ) + } + + /** + * Delete the indexed remote file for one attachment and remove its local mapping. + * @param version - exact request-image version to release. + * @param connection - endpoint and API-key snapshot. + * @param policy - expiry policy used to locate a reusable mapping. + * @param signal - request cancellation. + * @returns whether an indexed file existed and was deleted. + */ + async release( + version: RequestImageAttachment, + connection: DeepSeekFileConnection, + policy: DeepSeekFilePolicy, + signal?: AbortSignal, + ): Promise { + const scope = deepSeekFileScope(connection.baseURL, connection.apiKey) + const record = await this.index.get( + scope, + version.variantId, + this.now(), + policy.refreshMarginSeconds * 1_000, + ) + if (record === undefined) return false + await this.client(connection).delete(record.fileId, signal) + await this.index.remove(scope, version.variantId, record.fileId) + return true + } + + /** + * Delete the oldest provider files whose names identify harness ownership. + * @param connection - endpoint and API-key snapshot. + * @param count - positive maximum number of files to delete. + * @param signal - request cancellation. + * @returns number of successfully deleted files. + */ + async reclaimOldestOwned( + connection: DeepSeekFileConnection, + count: number, + signal?: AbortSignal, + ): Promise { + const client = this.client(connection) + let after: DeepSeekFileId | undefined + let deleted = 0 + while (deleted < count) { + const page = await client.list({ + ...after === undefined ? {} : { after }, + limit: 1_000, + order: 'asc', + ...signal === undefined ? {} : { signal }, + }) + for (const file of page.data) { + if (!file.filename.startsWith(OWNED_FILE_PREFIX)) continue + await client.delete(file.id, signal) + deleted += 1 + if (deleted === count) break + } + if (!page.hasMore || page.lastId === undefined || page.lastId === after) break + after = page.lastId + } + return deleted + } + + /** + * Delete every remote harness-owned file in the active API-key namespace and clear its index. + * @param connection - endpoint and API-key snapshot. + * @param signal - request cancellation. + * @returns number of deleted files. + */ + async releaseAll(connection: DeepSeekFileConnection, signal?: AbortSignal): Promise { + let total = 0 + for (;;) { + const deleted = await this.reclaimOldestOwned(connection, 1_000, signal) + total += deleted + if (deleted < 1_000) break + } + await this.index.clear(deepSeekFileScope(connection.baseURL, connection.apiKey)) + return total + } +} diff --git a/packages/llm/llm-deepseek/src/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts new file mode 100644 index 0000000000..90ddf20b8c --- /dev/null +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -0,0 +1,257 @@ +/** OpenAI-compatible DeepSeek Files API transport. @module dsh-llm-deepseek/files-api */ + +import { LlmError } from '@deepseek-ai/dsh-llm' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import { DeepSeekFileId } from './file-id.ts' +import type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' + +/** Minimum provider-supported file lifetime. */ +export const MIN_FILE_EXPIRY_SECONDS = 3_600 +/** Maximum provider-supported file lifetime. */ +export const MAX_FILE_EXPIRY_SECONDS = 2_592_000 +/** Maximum Files API upload size. */ +export const MAX_FILE_UPLOAD_BYTES = 128 * 1024 * 1024 +/** Current per-key file-count quota. */ +export const MAX_STORED_FILE_COUNT = 10_000 +/** Current per-key storage quota. */ +export const MAX_STORED_FILE_BYTES = 25 * 1024 * 1024 * 1024 + +/** Validated file object returned by the OpenAI-compatible endpoint. */ +export interface DeepSeekFileObject { + id: DeepSeekFileIdType + bytes: number + createdAt: number + filename: string + purpose: 'user_data' + expiresAt?: number +} + +/** One page returned by `GET /files`. */ +export interface DeepSeekFilePage { + data: DeepSeekFileObject[] + firstId?: DeepSeekFileIdType + lastId?: DeepSeekFileIdType + hasMore: boolean +} + +/** Files API operation failure with its HTTP status retained for recovery policy. */ +export class DeepSeekFilesError extends LlmError { + /** Parsed provider detail used only for error classification. */ + readonly detail: string + + /** + * @param message - user-readable provider failure. + * @param status - HTTP status returned by the Files API. + * @param detail - provider error fields joined for classification. + */ + constructor(message: string, status: number, detail: string) { + super(message, status === 401 || status === 403 + ? 'AUTH' + : status === 429 + ? 'RATE_LIMIT' + : status >= 500 + ? 'SERVER' + : 'FILES_API', { status }) + this.name = 'DeepSeekFilesError' + this.detail = detail + } +} + +/** + * Whether an upload failure reports a provider storage or file-count quota. + * @param error - Files API operation failure. + * @returns whether one bounded remote cleanup and upload retry may recover. + */ +export function isFilesQuotaError(error: unknown): error is DeepSeekFilesError { + return error instanceof DeepSeekFilesError + && /(?:quota|storage|stored files|file count|too many files)/iu.test(error.detail) +} + +interface FilesApiOptions { + baseURL: string + apiKey: string + fetch?: typeof fetch +} + +interface WireFileObject { + id?: unknown + object?: unknown + bytes?: unknown + created_at?: unknown + filename?: unknown + purpose?: unknown + expires_at?: unknown +} + +function invalidResponse(operation: string): LlmError { + return new LlmError(`DeepSeek Files API returned an invalid ${operation} response.`, 'INVALID_RESPONSE') +} + +function parseFileObject(value: unknown, operation: string): DeepSeekFileObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse(operation) + const wire = value as WireFileObject + if (typeof wire.id !== 'string' || wire.id.length === 0 + || wire.object !== 'file' + || !Number.isSafeInteger(wire.bytes) || (wire.bytes as number) < 0 + || !Number.isSafeInteger(wire.created_at) || (wire.created_at as number) < 0 + || typeof wire.filename !== 'string' || wire.filename.length === 0 + || wire.purpose !== 'user_data' + || (wire.expires_at !== undefined + && (!Number.isSafeInteger(wire.expires_at) || (wire.expires_at as number) < 0))) { + throw invalidResponse(operation) + } + return { + id: DeepSeekFileId(wire.id), + bytes: wire.bytes as number, + createdAt: wire.created_at as number, + filename: wire.filename, + purpose: 'user_data', + ...wire.expires_at === undefined ? {} : { expiresAt: wire.expires_at as number }, + } +} + +function providerErrorDetail(value: unknown): { message?: string; detail: string } { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return { detail: '' } + const error = (value as { error?: unknown }).error + if (error === null || typeof error !== 'object' || Array.isArray(error)) return { detail: '' } + const fields = error as { message?: unknown; type?: unknown; code?: unknown } + const message = typeof fields.message === 'string' ? fields.message : undefined + return { + ...message === undefined ? {} : { message }, + detail: [fields.code, fields.type, fields.message] + .filter((field): field is string => typeof field === 'string') + .join(' '), + } +} + +/** Direct client for the OpenAI-compatible `/files` endpoints. */ +export class DeepSeekFilesClient { + private readonly baseURL: string + private readonly apiKey: string + private readonly fetchImpl: typeof fetch + + /** + * @param options - endpoint, API-key snapshot, and optional test transport. + */ + constructor(options: FilesApiOptions) { + this.baseURL = options.baseURL.replace(/\/+$/u, '') + this.apiKey = options.apiKey + this.fetchImpl = options.fetch ?? globalThis.fetch + } + + private async request(path: string, init: RequestInit, signal?: AbortSignal): Promise { + let response: Response + try { + const headers = new Headers(init.headers) + headers.set('authorization', `Bearer ${this.apiKey}`) + response = await this.fetchImpl(`${this.baseURL}${path}`, { + ...init, + headers, + ...signal === undefined ? {} : { signal }, + }) + } catch (error: unknown) { + if (signal?.aborted) throw error + throw new LlmError(`DeepSeek Files API request to ${this.baseURL} failed`, 'TRANSPORT', { cause: error }) + } + if (response.ok) return response + let parsed: unknown + try { + parsed = await response.json() + } catch { + // A status remains sufficient to report the provider failure. + } + const { message, detail } = providerErrorDetail(parsed) + throw new DeepSeekFilesError( + message ?? `DeepSeek Files API error (HTTP ${response.status})`, + response.status, + detail, + ) + } + + /** + * Upload one image with an explicit expiry. + * @param input - deterministic request-version bytes, media type, filename, lifetime, and cancellation. + * @returns the validated provider file object, including `expires_at`. + */ + async upload(input: { + data: Uint8Array + mediaType: ImageMediaType + filename: string + expiresAfterSeconds: number + signal?: AbortSignal + }): Promise { + if (input.data.byteLength > MAX_FILE_UPLOAD_BYTES) { + throw new LlmError('DeepSeek Files API upload exceeds 128 MiB.', 'INVALID_REQUEST') + } + if (!Number.isSafeInteger(input.expiresAfterSeconds) + || input.expiresAfterSeconds < MIN_FILE_EXPIRY_SECONDS + || input.expiresAfterSeconds > MAX_FILE_EXPIRY_SECONDS) { + throw new LlmError('DeepSeek file expiry must be between 3600 and 2592000 seconds.', 'INVALID_REQUEST') + } + const form = new FormData() + form.set('purpose', 'user_data') + form.set('expires_after[anchor]', 'created_at') + form.set('expires_after[seconds]', String(input.expiresAfterSeconds)) + form.set('file', new Blob([Uint8Array.from(input.data).buffer], { type: input.mediaType }), input.filename) + const response = await this.request('/files', { method: 'POST', body: form }, input.signal) + const file = parseFileObject(await response.json(), 'upload') + if (file.expiresAt === undefined) throw invalidResponse('upload') + return file + } + + /** + * List one ascending or descending page of user-data files. + * @param options - pagination, ordering, and cancellation. + * @returns the validated page. + */ + async list(options: { + after?: DeepSeekFileIdType + limit?: number + order?: 'asc' | 'desc' + signal?: AbortSignal + } = {}): Promise { + const query = new URLSearchParams({ purpose: 'user_data' }) + if (options.after !== undefined) query.set('after', options.after) + if (options.limit !== undefined) query.set('limit', String(options.limit)) + if (options.order !== undefined) query.set('order', options.order) + const response = await this.request(`/files?${query.toString()}`, { method: 'GET' }, options.signal) + const value = await response.json() as unknown + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('list') + const wire = value as { object?: unknown; data?: unknown; first_id?: unknown; last_id?: unknown; has_more?: unknown } + if (wire.object !== 'list' || !Array.isArray(wire.data) || typeof wire.has_more !== 'boolean' + || (wire.first_id !== undefined && typeof wire.first_id !== 'string') + || (wire.last_id !== undefined && typeof wire.last_id !== 'string')) { + throw invalidResponse('list') + } + return { + data: wire.data.map(item => parseFileObject(item, 'list')), + ...typeof wire.first_id === 'string' ? { firstId: DeepSeekFileId(wire.first_id) } : {}, + ...typeof wire.last_id === 'string' ? { lastId: DeepSeekFileId(wire.last_id) } : {}, + hasMore: wire.has_more, + } + } + + /** + * Retrieve one file object. + * @param fileId - provider file identifier. + * @param signal - request cancellation. + * @returns the validated file object. + */ + async retrieve(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise { + const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'GET' }, signal) + return parseFileObject(await response.json(), 'retrieve') + } + + /** + * Delete one provider file. + * @param fileId - provider file identifier. + * @param signal - request cancellation. + */ + async delete(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise { + const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }, signal) + const value = await response.json() as unknown + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('delete') + const wire = value as { id?: unknown; object?: unknown; deleted?: unknown } + if (wire.id !== fileId || wire.object !== 'file' || wire.deleted !== true) throw invalidResponse('delete') + } +} diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 65b8aa3e3c..6def44f2d3 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -22,8 +22,17 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { DEFAULT_CONTEXT_WINDOW, - DEFAULT_MAX_REQUEST_IMAGE_BYTES, + DEFAULT_FILE_EXPIRY_SECONDS, + DEFAULT_FILE_QUOTA_CLEANUP_BATCH, + DEFAULT_FILE_REFRESH_MARGIN_SECONDS, + DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, + DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, + DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, + DEFAULT_MAX_IMAGES_PER_REQUEST, + DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_MAX_TOKENS, + DEFAULT_REQUEST_IMAGE_MAX_BYTES, + DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, } from './adapter.ts' @@ -31,12 +40,29 @@ import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter. export { DEFAULT_CONTEXT_WINDOW, - DEFAULT_MAX_REQUEST_IMAGE_BYTES, + DEFAULT_FILE_EXPIRY_SECONDS, + DEFAULT_FILE_QUOTA_CLEANUP_BATCH, + DEFAULT_FILE_REFRESH_MARGIN_SECONDS, + DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, + DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, + DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, + DEFAULT_MAX_IMAGES_PER_REQUEST, + DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_MAX_TOKENS, + DEFAULT_REQUEST_IMAGE_MAX_BYTES, + DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, } from './adapter.ts' export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' +export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts' +export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts' +export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts' +export type { DeepSeekFileObject, DeepSeekFilePage } from './files-api.ts' +export { DeepSeekFileId } from './file-id.ts' +export type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' +export { DeepSeekUploadIndex, deepSeekFileScope } from './upload-index.ts' +export type { DeepSeekUploadRecord } from './upload-index.ts' export type { RequestDefaults } from './serialize.ts' export type * from './types.ts' @@ -56,6 +82,8 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ name: 'DeepSeek-V4-Flash-Vision-Exp', contextWindow: DEFAULT_CONTEXT_WINDOW, inputModalities: ['text', 'image'], + imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, + imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, }, ] @@ -86,8 +114,20 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Maximum accumulated base64 image payload per request (default 20 MiB). */ - maxRequestImageBytes?: number + /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ + maxRequestFilesBytes?: number + /** Maximum number of file-referenced images per chat request (default 600). */ + maxImagesPerRequest?: number + /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ + imageOffloadByteQuantum?: number + /** Image-count removal step after the request exceeds its count bound (default 20). */ + imageOffloadCountQuantum?: number + /** Explicit lifetime assigned to each uploaded image (default seven days). */ + fileExpiresAfterSeconds?: number + /** Remaining lifetime below which an indexed file is replaced (default one hour). */ + fileRefreshMarginSeconds?: number + /** Oldest harness-owned files deleted before one quota-recovery upload retry (default 100). */ + fileQuotaCleanupBatch?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -99,6 +139,9 @@ const catalogModel: z = z.object({ contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), + imagePixelBudget: z.number().step(1).min(1), + imageMaxBytes: z.number().step(1).min(1), + imageDetail: z.union(['auto', 'low']), }) export const Config: z = z.object({ @@ -110,7 +153,13 @@ export const Config: z = z.object({ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), - maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES), + maxRequestFilesBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_FILES_BYTES), + maxImagesPerRequest: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_REQUEST), + imageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM), + imageOffloadCountQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM), + fileExpiresAfterSeconds: z.number().step(1).min(3_600).max(2_592_000).default(DEFAULT_FILE_EXPIRY_SECONDS), + fileRefreshMarginSeconds: z.number().step(1).min(0).default(DEFAULT_FILE_REFRESH_MARGIN_SECONDS), + fileQuotaCleanupBatch: z.number().step(1).min(1).max(1_000).default(DEFAULT_FILE_QUOTA_CLEANUP_BATCH), retryPolicy: RetryPolicySchema, }) @@ -160,6 +209,19 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee if (new Set(inputModalities).size !== inputModalities.length) { throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`) } + const hasImage = inputModalities.includes('image') + if (!hasImage && (model.imagePixelBudget !== undefined + || model.imageMaxBytes !== undefined || model.imageDetail !== undefined)) { + throw new Error(`llm-deepseek: text-only catalog model "${model.id}" cannot declare image request limits`) + } + if (model.imagePixelBudget !== undefined + && (!Number.isSafeInteger(model.imagePixelBudget) || model.imagePixelBudget <= 0)) { + throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be a positive safe integer`) + } + if (model.imageMaxBytes !== undefined + && (!Number.isSafeInteger(model.imageMaxBytes) || model.imageMaxBytes <= 0)) { + throw new Error(`llm-deepseek: catalog model "${model.id}" imageMaxBytes must be a positive safe integer`) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { @@ -169,6 +231,16 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, inputModalities: [...inputModalities], + ...hasImage + ? { + imagePixelBudget: model.imagePixelBudget + ?? (model.imageDetail === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET), + imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, + ...model.imageDetail === undefined ? {} : { imageDetail: model.imageDetail }, + } + : {}, } }) } @@ -207,9 +279,39 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - const maxRequestImageBytes = config.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES - if (!Number.isSafeInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) { - throw new Error('llm-deepseek: maxRequestImageBytes must be a positive safe integer') + const maxRequestFilesBytes = config.maxRequestFilesBytes ?? DEFAULT_MAX_REQUEST_FILES_BYTES + if (!Number.isSafeInteger(maxRequestFilesBytes) || maxRequestFilesBytes <= 0) { + throw new Error('llm-deepseek: maxRequestFilesBytes must be a positive safe integer') + } + const maxImagesPerRequest = config.maxImagesPerRequest ?? DEFAULT_MAX_IMAGES_PER_REQUEST + if (!Number.isSafeInteger(maxImagesPerRequest) || maxImagesPerRequest <= 0) { + throw new Error('llm-deepseek: maxImagesPerRequest must be a positive safe integer') + } + const imageOffloadByteQuantum = config.imageOffloadByteQuantum ?? DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM + if (!Number.isSafeInteger(imageOffloadByteQuantum) || imageOffloadByteQuantum <= 0) { + throw new Error('llm-deepseek: imageOffloadByteQuantum must be a positive safe integer') + } + const imageOffloadCountQuantum = config.imageOffloadCountQuantum ?? DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM + if (!Number.isSafeInteger(imageOffloadCountQuantum) || imageOffloadCountQuantum <= 0) { + throw new Error('llm-deepseek: imageOffloadCountQuantum must be a positive safe integer') + } + const fileExpiresAfterSeconds = config.fileExpiresAfterSeconds ?? DEFAULT_FILE_EXPIRY_SECONDS + if (!Number.isSafeInteger(fileExpiresAfterSeconds) + || fileExpiresAfterSeconds < 3_600 + || fileExpiresAfterSeconds > 2_592_000) { + throw new Error('llm-deepseek: fileExpiresAfterSeconds must be an integer from 3600 through 2592000') + } + const fileRefreshMarginSeconds = config.fileRefreshMarginSeconds ?? DEFAULT_FILE_REFRESH_MARGIN_SECONDS + if (!Number.isSafeInteger(fileRefreshMarginSeconds) + || fileRefreshMarginSeconds < 0 + || fileRefreshMarginSeconds >= fileExpiresAfterSeconds) { + throw new Error('llm-deepseek: fileRefreshMarginSeconds must be a non-negative integer below fileExpiresAfterSeconds') + } + const fileQuotaCleanupBatch = config.fileQuotaCleanupBatch ?? DEFAULT_FILE_QUOTA_CLEANUP_BATCH + if (!Number.isSafeInteger(fileQuotaCleanupBatch) + || fileQuotaCleanupBatch < 1 + || fileQuotaCleanupBatch > 1_000) { + throw new Error('llm-deepseek: fileQuotaCleanupBatch must be an integer from 1 through 1000') } return { apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), @@ -224,7 +326,15 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, models: resolveModels(config.models), streamIdleTimeoutMs, - maxRequestImageBytes, + maxRequestFilesBytes, + maxImagesPerRequest, + imageOffloadByteQuantum, + imageOffloadCountQuantum, + filePolicy: { + expiresAfterSeconds: fileExpiresAfterSeconds, + refreshMarginSeconds: fileRefreshMarginSeconds, + quotaCleanupBatch: fileQuotaCleanupBatch, + }, retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), } } diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 498b3fb2f7..f066808b99 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,19 +1,19 @@ /** * Serialize harness messages into DeepSeek chat completions. Text-only * requests retain string user content; the image path resolves durable - * attachments into ordered data-URL parts. Tool-result images follow their + * attachments into ordered Files API parts. Tool-result images follow their * string-only tool messages in a separate user message. * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImagePreviewText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { - WireImageContentPart, + WireFileContentPart, WireMessage, WireRequest, + WireTextContentPart, WireTool, WireUserContentPart, } from './types.ts' @@ -31,12 +31,28 @@ interface ResolvedThinking { /** Dependencies required only when the request contains image input. */ export interface ImageSerializationOptions { - /** Durable resolver for canonical image references. */ - attachments: AttachmentStore - /** Positive bound on accumulated base64 image payload. */ - maxRequestImageBytes: number - /** Cancellation shared with the provider request. */ - signal: AbortSignal + /** Resolve a retained request version to a reusable DeepSeek file id. */ + resolveFileId: ( + version: RequestImageAttachment, + block: Extract, + location: ImageWireLocation, + ) => Promise + /** Request versions prepared before offload selection, keyed by master attachment id. */ + requestImages: ReadonlyMap + /** Positive bound on accumulated referenced image bytes. */ + maxRequestFilesBytes: number + /** Maximum referenced images in one request. */ + maxImagesPerRequest?: number + /** Raw-byte removal step applied after the request exceeds its byte bound. */ + byteQuantum?: number + /** Image-count removal step applied after the request exceeds its count bound. */ + countQuantum?: number +} + +/** Durable message and image ordinal used in provider diagnostics. */ +export interface ImageWireLocation { + message: number + image: number } const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:' @@ -98,33 +114,40 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { } } -/** Resolve one durable image into its transient DeepSeek data-URL part. */ -async function imagePart( - block: Extract, - attachments: AttachmentStore, - signal: AbortSignal, -): Promise { - try { - const stored = await attachments.readImage(block.attachment, signal) - return { - type: 'image_url', - image_url: { - url: `data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString('base64')}`, - }, - } - } catch (error: unknown) { - if (error instanceof AttachmentError) { - throw new LlmError(error.message, error.code, { cause: error }) - } - throw error +/** Describe the exact request preview and its model-callable coordinate system. */ +function imageHandle(version: RequestImageAttachment, precededByContent: boolean): WireTextContentPart { + return { + type: 'text', + text: `${precededByContent ? '\n' : ''}${requestImagePreviewText(version)}`, } } +/** Resolve one durable image into its descriptor and transient DeepSeek file-id part. */ +async function imageParts( + block: Extract, + images: ImageSerializationOptions, + location: ImageWireLocation, + precededByContent: boolean, +): Promise<[WireTextContentPart, WireFileContentPart]> { + const version = images.requestImages.get(block.attachment.attachmentId) + if (version === undefined) { + throw new LlmError( + `DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, + 'INVALID_REQUEST', + ) + } + return [ + imageHandle(version, precededByContent), + { type: 'file', file_id: await images.resolveFileId(version, block, location) }, + ] +} + /** Convert user or nested tool-result blocks into ordered wire parts. */ async function contentParts( blocks: readonly ContentBlock[], - attachments: AttachmentStore, - signal: AbortSignal, + images: ImageSerializationOptions, + message: number, + nextImage: { value: number }, ): Promise { const parts: WireUserContentPart[] = [] for (const block of blocks) { @@ -133,10 +156,11 @@ async function contentParts( if (block.text.length > 0) parts.push({ type: 'text', text: block.text }) break case 'image': - parts.push(await imagePart(block, attachments, signal)) + nextImage.value += 1 + parts.push(...await imageParts(block, images, { message, image: nextImage.value }, parts.length > 0)) break case 'tool-result': - parts.push(...await contentParts(block.content, attachments, signal)) + parts.push(...await contentParts(block.content, images, message, nextImage)) break default: // Other merge-extensible blocks are not DeepSeek user-input vocabulary. @@ -150,7 +174,7 @@ async function contentParts( function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { const text: string[] = [] for (const part of parts) { - if (part.type === 'image_url') return [...parts] + if (part.type === 'file') return [...parts] text.push(part.text) } return text.join('') @@ -236,18 +260,16 @@ export function serializeMessages(messages: Message[]): WireMessage[] { * Consecutive tool results keep string `tool` messages and share one following * user message containing their images. * @param messages - transient request history after request-size offloading. - * @param attachments - durable image resolver. - * @param signal - cancellation for attachment reads. + * @param images - prepared request versions and reusable provider file-id resolver. * @returns ordered DeepSeek wire messages. */ export async function serializeMessagesWithImages( messages: readonly Message[], - attachments: AttachmentStore, - signal: AbortSignal, + images: ImageSerializationOptions, ): Promise { assertSupportedImageRoles(messages) const wire: WireMessage[] = [] - let pendingToolImages: WireImageContentPart[] = [] + let pendingToolImages: WireFileContentPart[] = [] const flushToolImages = (): void => { if (pendingToolImages.length === 0) return wire.push({ @@ -257,7 +279,8 @@ export async function serializeMessagesWithImages( pendingToolImages = [] } - for (const message of messages) { + for (const [messageIndex, message] of messages.entries()) { + const nextImage = { value: 0 } if (message.role === 'system') { flushToolImages() wire.push({ role: 'system', content: flattenText(message.content) }) @@ -273,7 +296,7 @@ export async function serializeMessagesWithImages( const toolResults = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) - const content = userContent(await contentParts(regular, attachments, signal)) + const content = userContent(await contentParts(regular, images, messageIndex + 1, nextImage)) if (content.length > 0 || toolResults.length === 0) { flushToolImages() wire.push({ @@ -282,15 +305,15 @@ export async function serializeMessagesWithImages( }) } for (const result of toolResults) { - const parts = await contentParts(result.content, attachments, signal) - const images = parts.filter((part): part is WireImageContentPart => part.type === 'image_url') + const parts = await contentParts(result.content, images, messageIndex + 1, nextImage) + const fileParts = parts.filter((part): part is WireFileContentPart => part.type === 'file') const text = parts.filter(part => part.type === 'text').map(part => part.text).join('') wire.push({ role: 'tool', tool_call_id: result.toolCallId, - content: text || (images.length > 0 ? '(see attached image)' : '(no output)'), + content: text || (fileParts.length > 0 ? '(see attached image)' : '(no output)'), }) - pendingToolImages.push(...images) + pendingToolImages.push(...fileParts) } } flushToolImages() @@ -351,8 +374,8 @@ export function serializeRequest( /** * Build one image-capable request while keeping durable bytes out of session - * messages. Oversized oldest images become deterministic text before any - * attachment read. + * messages. Oversized oldest images become deterministic text after their + * exact request-version byte lengths are known and before provider upload. * @param options - harness request containing image-capable user content. * @param images - attachment resolver, request bound, and cancellation. * @param defaults - adapter-level thinking defaults. @@ -364,11 +387,24 @@ export async function serializeRequestWithImages( defaults: RequestDefaults = {}, ): Promise { assertSupportedImageRoles(options.messages) - const requestMessages = offloadRequestImages(options.messages, images.maxRequestImageBytes) + const requestMessages = offloadRequestImagesWithPolicy(options.messages, { + representation: 'raw', + byteLength: (ref) => { + const version = images.requestImages.get(ref.attachmentId) + if (version === undefined) { + throw new LlmError(`DeepSeek request image ${ref.attachmentId} was not prepared.`, 'INVALID_REQUEST') + } + return version.bytes + }, + maxBytes: images.maxRequestFilesBytes, + ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, + ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, + ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, + }) const messages: WireMessage[] = [] if (options.system !== undefined) { messages.push({ role: 'system', content: options.system }) } - messages.push(...await serializeMessagesWithImages(requestMessages, images.attachments, images.signal)) + messages.push(...await serializeMessagesWithImages(requestMessages, images)) return requestWithMessages(options, messages, defaults) } diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 93c7f48a36..54f39b095b 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -41,14 +41,14 @@ export interface WireTextContentPart { text: string } -/** Base64 data URL part inside a multimodal user message. */ -export interface WireImageContentPart { - type: 'image_url' - image_url: { url: string } +/** Files API reference inside a multimodal user message. */ +export interface WireFileContentPart { + type: 'file' + file_id: string } /** Ordered input part accepted by a multimodal user message. */ -export type WireUserContentPart = WireTextContentPart | WireImageContentPart +export type WireUserContentPart = WireTextContentPart | WireFileContentPart /** User-role message: text-only string or ordered multimodal input. */ export interface WireUserMessage { diff --git a/packages/llm/llm-deepseek/src/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts new file mode 100644 index 0000000000..12d433b760 --- /dev/null +++ b/packages/llm/llm-deepseek/src/upload-index.ts @@ -0,0 +1,225 @@ +/** Durable DeepSeek attachment-to-file-id index. @module dsh-llm-deepseek/upload-index */ + +import { createHash } from 'node:crypto' +import { readFile, mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentId, ImageVariantId as ImageVariantIdType } from '@deepseek-ai/dsh-attachment' +import { DeepSeekFileId, DeepSeekFileScope } from './file-id.ts' +import type { DeepSeekFileId as DeepSeekFileIdType, DeepSeekFileScope as DeepSeekFileScopeType } from './file-id.ts' + +/** One durable remote upload mapping. Unix times are milliseconds. */ +export interface DeepSeekUploadRecord { + scope: DeepSeekFileScopeType + /** Provider-independent master attachment from which the uploaded request version was derived. */ + masterAttachmentId: AttachmentId + /** Complete request transformation identity, including crop and encoder parameters. */ + variantId: ImageVariantIdType + fileId: DeepSeekFileIdType + bytes: number + createdAt: number + expiresAt: number +} + +interface StoredIndex { + formatVersion: 2 + records: DeepSeekUploadRecord[] +} + +class InvalidUploadIndexError extends Error {} + +/** Candidate commit outcome when another process already published a reusable upload. */ +export interface UploadIndexCommit { + record: DeepSeekUploadRecord + accepted: boolean +} + +/** + * Derive a non-secret stable index namespace without persisting or logging the API key. + * @param baseURL - normalized provider endpoint namespace. + * @param apiKey - resolved credential used only as hash input. + * @returns branded SHA-256 namespace digest. + */ +export function deepSeekFileScope(baseURL: string, apiKey: string): DeepSeekFileScopeType { + const digest = createHash('sha256') + .update(baseURL.replace(/\/+$/u, '')) + .update('\0') + .update(apiKey) + .digest('hex') + return DeepSeekFileScope(digest) +} + +function absent(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +function parseRecord(value: unknown): DeepSeekUploadRecord { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new InvalidUploadIndexError('llm-deepseek: upload index contains a non-object record') + } + const record = value as Record + if (typeof record.scope !== 'string' || !/^[0-9a-f]{64}$/u.test(record.scope) + || typeof record.masterAttachmentId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.masterAttachmentId) + || typeof record.variantId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.variantId) + || typeof record.fileId !== 'string' || record.fileId.length === 0 + || !Number.isSafeInteger(record.bytes) || (record.bytes as number) < 0 + || !Number.isSafeInteger(record.createdAt) || (record.createdAt as number) < 0 + || !Number.isSafeInteger(record.expiresAt) || (record.expiresAt as number) < 0) { + throw new InvalidUploadIndexError('llm-deepseek: upload index contains an invalid record') + } + return { + scope: DeepSeekFileScope(record.scope), + masterAttachmentId: record.masterAttachmentId as AttachmentId, + variantId: ImageVariantId(record.variantId), + fileId: DeepSeekFileId(record.fileId), + bytes: record.bytes as number, + createdAt: record.createdAt as number, + expiresAt: record.expiresAt as number, + } +} + +function parseIndex(text: string): StoredIndex { + let value: unknown + try { + value = JSON.parse(text) + } catch (error: unknown) { + throw new InvalidUploadIndexError('llm-deepseek: upload index is not valid JSON', { cause: error }) + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new InvalidUploadIndexError('llm-deepseek: upload index is not an object') + } + const index = value as { formatVersion?: unknown; records?: unknown } + if (index.formatVersion !== 2 || !Array.isArray(index.records)) { + throw new InvalidUploadIndexError('llm-deepseek: unsupported upload index format') + } + const records = index.records.map(parseRecord) + const keys = new Set() + for (const record of records) { + const key = `${record.scope}\0${record.variantId}` + if (keys.has(key)) throw new InvalidUploadIndexError('llm-deepseek: upload index contains duplicate mappings') + keys.add(key) + } + return { formatVersion: 2, records } +} + +function reusable(record: DeepSeekUploadRecord, now: number, refreshMarginMs: number): boolean { + return record.expiresAt - now > refreshMarginMs +} + +/** Atomic local index shared by every DeepSeek session in this DSH home. */ +export class DeepSeekUploadIndex { + /** Absolute owner-private JSON index path. */ + readonly path: string + + /** + * @param path - explicit test path; omission uses `DSH_HOME/llm-deepseek/files-v2.json`. + */ + constructor(path = join(resolveDshHome(), 'llm-deepseek', 'files-v2.json')) { + this.path = path + } + + private async load(): Promise { + try { + return parseIndex(await readFile(this.path, 'utf8')) + } catch (error: unknown) { + if (absent(error) || error instanceof InvalidUploadIndexError) { + return { formatVersion: 2, records: [] } + } + throw error + } + } + + private async save(index: StoredIndex): Promise { + await writeFileAtomic(this.path, `${JSON.stringify(index, undefined, 2)}\n`, { + mode: 0o600, + dirMode: 0o700, + }) + } + + /** + * Read one reusable mapping. + * @param scope - endpoint/API-key namespace. + * @param variantId - complete request-image transformation identity. + * @param now - current Unix time in milliseconds. + * @param refreshMarginMs - remaining lifetime below which a mapping is not reused. + * @returns the mapping when it has enough lifetime remaining. + */ + async get( + scope: DeepSeekFileScopeType, + variantId: ImageVariantIdType, + now: number, + refreshMarginMs: number, + ): Promise { + const record = (await this.load()).records.find(candidate => ( + candidate.scope === scope && candidate.variantId === variantId + )) + return record !== undefined && reusable(record, now, refreshMarginMs) ? record : undefined + } + + /** + * Publish a completed upload unless another process already published a reusable mapping. + * @param candidate - completed remote upload. + * @param now - current Unix time in milliseconds. + * @param refreshMarginMs - minimum reusable remaining lifetime. + * @returns the winning record and whether the candidate entered the index. + */ + async commit( + candidate: DeepSeekUploadRecord, + now: number, + refreshMarginMs: number, + ): Promise { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }) + return withFileLock(this.path, async () => { + const index = await this.load() + const existing = index.records.find(record => ( + record.scope === candidate.scope + && record.variantId === candidate.variantId + && reusable(record, now, refreshMarginMs) + )) + if (existing !== undefined) return { record: existing, accepted: false } + const records = index.records.filter(record => ( + reusable(record, now, refreshMarginMs) + && !(record.scope === candidate.scope && record.variantId === candidate.variantId) + )) + records.push(candidate) + await this.save({ formatVersion: 2, records }) + return { record: candidate, accepted: true } + }) + } + + /** + * Remove one exact mapping without deleting a concurrently installed successor. + * @param scope - endpoint/API-key namespace. + * @param variantId - complete request-image transformation identity. + * @param fileId - exact remote generation being invalidated. + */ + async remove( + scope: DeepSeekFileScopeType, + variantId: ImageVariantIdType, + fileId: DeepSeekFileIdType, + ): Promise { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }) + await withFileLock(this.path, async () => { + const index = await this.load() + const records = index.records.filter(record => !( + record.scope === scope && record.variantId === variantId && record.fileId === fileId + )) + if (records.length !== index.records.length) await this.save({ formatVersion: 2, records }) + }) + } + + /** + * Remove every local mapping for one remote namespace. + * @param scope - endpoint/API-key namespace. + */ + async clear(scope: DeepSeekFileScopeType): Promise { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }) + await withFileLock(this.path, async () => { + const index = await this.load() + const records = index.records.filter(record => record.scope !== scope) + if (records.length !== index.records.length) await this.save({ formatVersion: 2, records }) + }) + } +} diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 5ac490b110..c52195433b 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,15 +1,19 @@ +import { readFileSync } from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createHash } from 'node:crypto' +import { randomBytes } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' -import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, + SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -29,42 +33,70 @@ const FLASH = 'deepseek-v4-flash' const PRO = 'deepseek-v4-pro' const VISION = 'deepseek-v4-flash-vision-exp' const VISION_E2E_ENABLED = process.env.DEEPSEEK_VISION_E2E === '1' -const RED_IMAGE = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - 'base64', -) -const RED_IMAGE_REF: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${createHash('sha256').update(RED_IMAGE).digest('hex')}`), - mediaType: 'image/png', - bytes: RED_IMAGE.byteLength, - width: 1, - height: 1, -} +const TEST_PNG = Uint8Array.from(readFileSync( + new URL('../../llm-pi-ai/tests/fixtures/qr-code.png', import.meta.url), +)) +const contexts: Context[] = [] +let identityHome: string class E2eAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { - maxImageBytes: 1024, + maxImageBytes: TEST_PNG.byteLength, maxImagesPerMessage: 1, - maxMessageImageBytes: 1024, - maxImagePixels: 1, - maxImageDimension: 1, + maxMessageImageBytes: TEST_PNG.byteLength, + maxImagePixels: 256 * 256, + maxImageDimension: 256, mediaTypes: ['image/png'], } + readonly ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${randomBytes(32).toString('hex')}`), + mediaType: 'image/png', + bytes: TEST_PNG.byteLength, + width: 256, + height: 256, + name: 'files-api-e2e.png', + } + readonly version: RequestImageAttachment = { + variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`), + master: this.ref, + data: TEST_PNG, + mediaType: 'image/png', + bytes: TEST_PNG.byteLength, + width: 256, + height: 256, + depth: 'uchar', + space: 'srgb', + hasAlpha: false, + } validateImage(_input: SaveImageAttachment): Promise { return Promise.resolve() } - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve(RED_IMAGE_REF) + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve({ + ref: this.ref, + source: { + mediaType: this.ref.mediaType, + bytes: this.ref.bytes, + width: this.ref.width, + height: this.ref.height, + }, + }) } - readImage(_ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { - return Promise.resolve({ ref: RED_IMAGE_REF, data: RED_IMAGE }) + readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref, data: TEST_PNG }) + } + + override readImageRequest( + _ref: ImageAttachmentRef, + _policy: ImageRequestPolicy, + _signal?: AbortSignal, + ): Promise { + return Promise.resolve(this.version) } } -const contexts: Context[] = [] -let identityHome: string beforeEach(async () => { identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-')) @@ -82,6 +114,7 @@ async function harness(_model: string, config: Partial = {}) { afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + vi.unstubAllGlobals() vi.unstubAllEnvs() await rm(identityHome, { recursive: true, force: true }) }) @@ -111,23 +144,46 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { - it.skipIf(!VISION_E2E_ENABLED)('recognizes a deterministic image with the official vision model', async () => { - const ctx = await harness(VISION, { - thinking: 'disabled', - }) - const result = await assemble(ctx, { - model: VISION, - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'This image is one solid color. Reply with only its English color name.' }, - { type: 'image', attachment: RED_IMAGE_REF }, - ], - source: { kind: 'plugin', plugin: 'test' }, - })], - maxTokens: 50, - }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('red') + it.skipIf(!VISION_E2E_ENABLED)('uses the built-in official route to upload, reference, and delete one image', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const baseURL = process.env.DEEPSEEK_BASE_URL ?? LlmDeepSeek.PUBLIC_BASE_URL + const ctx = await harness(VISION, { baseURL }) + await ctx.plugin(E2eAttachmentStore) + const attachments = ctx.attachments as E2eAttachmentStore + let uploadedFile: LlmDeepSeek.DeepSeekFileIdType | undefined + const nativeFetch = globalThis.fetch + const observedFetch: typeof fetch = async (input, init) => { + const response = await nativeFetch(input, init) + const url = new URL(input instanceof Request ? input.url : input) + const method = init?.method ?? (input instanceof Request ? input.method : 'GET') + if (method === 'POST' && url.pathname.endsWith('/files') && response.ok) { + const value = await response.clone().json() as { id?: unknown } + if (typeof value.id === 'string') uploadedFile = LlmDeepSeek.DeepSeekFileId(value.id) + } + return response + } + vi.stubGlobal('fetch', observedFetch) + const files = new LlmDeepSeek.DeepSeekFilesClient({ baseURL, apiKey: key }) + + try { + const result = await assemble(ctx, { + model: VISION, + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'Briefly describe this image.' }, + { type: 'image', attachment: attachments.ref }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + maxTokens: 100, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).trim().length).toBeGreaterThan(0) + expect(uploadedFile).toMatch(/^file-api-/u) + } finally { + if (uploadedFile !== undefined) await files.delete(uploadedFile) + } }) it('serves a real request with the key held only by a credentials-local document', async () => { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5bcccb9d99..e37a1a882e 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -3,8 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import LlmRuntime, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, @@ -74,6 +74,41 @@ const imageRef: ImageAttachmentRef = { height: 1, } +function requestImage(ref = imageRef): RequestImageAttachment { + return { + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + master: ref, + data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + depth: 'uchar', + space: 'srgb', + hasAlpha: true, + } +} + +function attachmentStoreOf( + project: (ref: ImageAttachmentRef, policy: unknown, signal?: AbortSignal) => Promise, +): { + store: AttachmentStore + readImageRequest: ReturnType> + readImageRequests: ReturnType +} { + const readImageRequest = vi.fn(project) + const readImageRequests = vi.fn(async ( + refs: readonly ImageAttachmentRef[], + policy: unknown, + signal?: AbortSignal, + ) => Promise.all(refs.map(ref => readImageRequest(ref, policy, signal)))) + return { + store: { readImageRequest, readImageRequests } as unknown as AttachmentStore, + readImageRequest, + readImageRequests, + } +} + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -108,16 +143,16 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') }) - it('sends a durable image as a base64 data URL for the vision model', async () => { + it('uploads a durable image once and sends only its Files API id to the vision model', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const signalSeen: (AbortSignal | undefined)[] = [] - const attachments = { - readImage: vi.fn((ref: ImageAttachmentRef, signal?: AbortSignal) => { - signalSeen.push(signal) - return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) - }), - } as unknown as AttachmentStore - const adapter = adapterOf({ baseURL: server.url }, attachments) + const policies: unknown[] = [] + const attachmentMocks = attachmentStoreOf((ref, policy, signal) => { + signalSeen.push(signal) + policies.push(policy) + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) await drain(adapter.stream({ provider: 'deepseek-official', @@ -137,11 +172,228 @@ describe('DeepSeekAdapter against a mock server', () => { role: 'user', content: [ { type: 'text', text: 'describe ' }, - { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}`) as string }, + { type: 'file', file_id: 'file-api-1' }, ], }], }) + expect(server.fileRequests).toEqual([{ + method: 'POST', + path: '/files', + filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, + bytes: 3, + }]) expect(signalSeen[0]).toBeInstanceOf(AbortSignal) + expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }]) + }) + + it('reuses the exact request version between agent and compaction calls', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + const messages = [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'plugin' as const, plugin: 'test' }, + })] + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages })) + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages, + purpose: 'compaction', + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(1) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, + ]) + expect(server.headers[1]?.['x-deepseek-harness-compact']).toBe('1') + }) + + it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + let failure: unknown + try { + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining( + `normalized image "${imageRef.attachmentId}" at message 1, image 1`, + ) as string, + cause: { message: raw }, + }) + expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') + expect((failure as Error).message).toContain('unsupported image payload') + expect((failure as Error).message).not.toBe(raw) + }) + + it.each([ + 'file_id file-api-1 expired', + 'file_not_found', + 'file_id file-api-1 deleted', + 'invalid file_id file-api-1', + ])('reuploads once when chat rejects a Files API reference as %s', async (providerMessage) => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: providerMessage } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachmentMocks.store) + const options = { + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'plugin' as const, plugin: 'test' }, + })], + } + + await drain(adapter.stream(options)) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-2' }] }] }, + ]) + expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) + }) + + it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-2 expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(3) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) + .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-2' }]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) + .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-3' }]) + }) + + it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file reference expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(4) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) + .toEqual([{ type: 'file', file_id: 'file-api-3' }, { type: 'file', file_id: 'file-api-4' }]) + }) + + it('returns the second stale-file rejection without a third chat attempt', async () => { + const stale = JSON.stringify({ error: { message: 'file_id file-api-1 expired' } }) + const server = await mockServer([ + { kind: 'http-error', status: 400, body: stale }, + { kind: 'http-error', status: 400, body: stale }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'file_id file-api-1 expired' }) + expect(server.requests).toHaveLength(2) + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) }) it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( @@ -1057,17 +1309,17 @@ describe('plugin registration and config', () => { ) it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid request image bound %s', - async (maxRequestImageBytes) => { - expect(() => resolveAdapterOptions({ maxRequestImageBytes })) - .toThrow(/maxRequestImageBytes must be a positive safe integer/) + 'rejects invalid request file bound %s', + async (maxRequestFilesBytes) => { + expect(() => resolveAdapterOptions({ maxRequestFilesBytes })) + .toThrow(/maxRequestFilesBytes must be a positive safe integer/) const ctx = new Context() await ctx.plugin(LlmRuntime) await expect(ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1', - maxRequestImageBytes, - })).rejects.toThrow(/maxRequestImageBytes/) + maxRequestFilesBytes, + })).rejects.toThrow(/maxRequestFilesBytes/) expect(ctx.llm.listProviders()).toEqual([]) }, ) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index c048f68920..c0a2e29750 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -4,10 +4,12 @@ import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmRuntime, { createUserMessage, INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' -import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -54,6 +56,25 @@ class StaticAttachmentStore extends AttachmentStore { readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) } + + override readImageRequest( + ref: ImageAttachmentRef, + _policy: ImageRequestPolicy, + _signal?: AbortSignal, + ): Promise { + return Promise.resolve({ + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + master: ref, + data: Uint8Array.of(1, 2, 3), + mediaType: ref.mediaType, + bytes: 3, + width: ref.width, + height: ref.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: true, + }) + } } const cleanups: Array<() => Promise> = [] @@ -168,7 +189,7 @@ describe('request-level dynamic configuration', () => { ]) }) - it('applies a changed request image bound to the next request', async () => { + it('applies changed request file limits to the next request', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') const dir = await home() const server = await mockServer([ @@ -185,14 +206,14 @@ describe('request-level dynamic configuration', () => { })] await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) - await ctx.settings.update(NS, { maxRequestImageBytes: 4 }) + await ctx.settings.update(NS, { maxRequestFilesBytes: 4, imageOffloadByteQuantum: 2 }) await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content - expect(JSON.stringify(first).match(/"type":"image_url"/g)).toHaveLength(2) + expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit') - expect(JSON.stringify(second).match(/"type":"image_url"/g)).toHaveLength(1) + expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) }) it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts new file mode 100644 index 0000000000..041fd0a0c6 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -0,0 +1,135 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { DeepSeekFileStore } from '../src/file-store.ts' +import { DeepSeekUploadIndex } from '../src/upload-index.ts' + +const REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} +const VERSION: RequestImageAttachment = { + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + master: REF, + data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + depth: 'uchar', + space: 'srgb', + hasAlpha: true, +} +const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' } +const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 } +const NOW = 1_700_000_000_000 + +function requestUrl(input: string | URL | Request): string { + if (typeof input === 'string') return input + return input instanceof URL ? input.href : input.url +} + +function uploadFetch(now: () => number = () => NOW) { + let uploads = 0 + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') { + uploads += 1 + const createdAt = now() / 1_000 + return new Response(JSON.stringify({ + id: `file-api-${uploads}`, + object: 'file', + bytes: 3, + created_at: createdAt, + filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, + purpose: 'user_data', + expires_at: createdAt + POLICY.expiresAfterSeconds, + }), { status: 200 }) + } + if (init?.method === 'DELETE') { + const id = requestUrl(_url).split('/').at(-1) + return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }) + } + throw new Error('unexpected Files API request') + }) as typeof fetch + return { fetchImpl, uploads: () => uploads } +} + +describe('DeepSeekFileStore', () => { + it('singleflights the first upload and reuses the durable mapping across store instances', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + const remote = uploadFetch() + const first = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl }) + + const [a, b] = await Promise.all([ + first.ensureUploaded(VERSION, CONNECTION, POLICY), + first.ensureUploaded(VERSION, CONNECTION, POLICY), + ]) + expect(a.record.fileId).toBe('file-api-1') + expect(b.record.fileId).toBe('file-api-1') + expect(remote.uploads()).toBe(1) + + const resumed = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl }) + await expect(resumed.ensureUploaded(VERSION, CONNECTION, POLICY)) + .resolves.toMatchObject({ record: { fileId: 'file-api-1' }, uploaded: false }) + expect(remote.uploads()).toBe(1) + }) + + it('does not persist an upload whose response is missing and retries on the next request', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + const good = uploadFetch() + let first = true + const fetchImpl = vi.fn((url: string | URL | Request, init?: RequestInit) => { + if (first) { + first = false + return Promise.resolve(new Response('', { status: 204 })) + } + return good.fetchImpl(url, init) + }) as typeof fetch + const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl }) + + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)) + .rejects.toBeInstanceOf(Error) + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)) + .resolves.toMatchObject({ record: { fileId: 'file-api-1' }, uploaded: true }) + }) + + it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + let now = NOW + const remote = uploadFetch(() => now) + const store = new DeepSeekFileStore({ index, now: () => now, fetch: remote.fetchImpl }) + + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)) + .resolves.toMatchObject({ record: { fileId: 'file-api-1' }, uploaded: true }) + now = NOW + (POLICY.expiresAfterSeconds - POLICY.refreshMarginSeconds) * 1_000 - 1 + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)) + .resolves.toMatchObject({ record: { fileId: 'file-api-1' }, uploaded: false }) + now += 1 + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)) + .resolves.toMatchObject({ record: { fileId: 'file-api-2' }, uploaded: true }) + + expect(remote.uploads()).toBe(2) + expect(vi.mocked(remote.fetchImpl).mock.calls.every(([, init]) => init?.method === 'POST')).toBe(true) + }) + + it('releases an indexed file through DELETE and removes only that mapping', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + const remote = uploadFetch() + const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl }) + await store.ensureUploaded(VERSION, CONNECTION, POLICY) + + await expect(store.release(VERSION, CONNECTION, POLICY)).resolves.toBe(true) + await expect(store.release(VERSION, CONNECTION, POLICY)).resolves.toBe(false) + expect(remote.fetchImpl).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts new file mode 100644 index 0000000000..752c659a7f --- /dev/null +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { DeepSeekFileId } from '../src/file-id.ts' +import { DeepSeekFilesClient, isFilesQuotaError } from '../src/files-api.ts' + +function requestUrl(input: string | URL | Request): string { + if (typeof input === 'string') return input + return input instanceof URL ? input.href : input.url +} + +function file(overrides: Record = {}) { + return { + id: 'file-api-one', + object: 'file', + bytes: 3, + created_at: 1_700_000_000, + filename: 'image.png', + purpose: 'user_data', + expires_at: 1_700_604_800, + ...overrides, + } +} + +describe('DeepSeekFilesClient', () => { + it('uploads multipart bytes with the required purpose and explicit expiry', async () => { + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + expect(requestUrl(url)).toBe('https://api.deepseek.com/files') + expect(init?.method).toBe('POST') + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer key') + const form = init?.body + expect(form).toBeInstanceOf(FormData) + if (!(form instanceof FormData)) throw new Error('expected multipart body') + expect(form.get('purpose')).toBe('user_data') + expect(form.get('expires_after[anchor]')).toBe('created_at') + expect(form.get('expires_after[seconds]')).toBe('604800') + const blob = form.get('file') + expect(blob).toBeInstanceOf(Blob) + expect((blob as Blob).size).toBe(3) + return new Response(JSON.stringify(file()), { status: 200 }) + }) as typeof fetch + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl }) + + await expect(client.upload({ + data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', + filename: 'image.png', + expiresAfterSeconds: 604_800, + })).resolves.toEqual({ + id: DeepSeekFileId('file-api-one'), + bytes: 3, + createdAt: 1_700_000_000, + filename: 'image.png', + purpose: 'user_data', + expiresAt: 1_700_604_800, + }) + }) + + it('validates list, retrieve, and delete responses', async () => { + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const target = requestUrl(url) + if (target.includes('?')) { + return new Response(JSON.stringify({ + object: 'list', data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false, + }), { status: 200 }) + } + if (init?.method === 'DELETE') { + return new Response(JSON.stringify({ id: 'file-api-one', object: 'file', deleted: true }), { status: 200 }) + } + return new Response(JSON.stringify(file()), { status: 200 }) + }) as typeof fetch + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + + await expect(client.list({ limit: 20, order: 'desc' })).resolves.toMatchObject({ + data: [{ id: 'file-api-one' }], firstId: 'file-api-one', lastId: 'file-api-one', hasMore: false, + }) + await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one' }) + await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined() + }) + + it('refuses an upload response that omits the requested expiry', async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response( + JSON.stringify(file({ expires_at: undefined })), + { status: 200 }, + ))) as typeof fetch + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + + await expect(client.upload({ + data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, + })).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) + }) + + it('retains quota error detail for the one cleanup retry policy', async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + error: { message: 'user storage quota exceeded', type: 'invalid_request_error', code: 'file_quota' }, + }), { status: 400 }))) as typeof fetch + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + + const error = await client.upload({ + data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, + }).catch((caught: unknown) => caught) + expect(isFilesQuotaError(error)).toBe(true) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/mock-server.ts b/packages/llm/llm-deepseek/tests/mock-server.ts index cdb499e143..819a945e93 100644 --- a/packages/llm/llm-deepseek/tests/mock-server.ts +++ b/packages/llm/llm-deepseek/tests/mock-server.ts @@ -13,6 +13,8 @@ export interface MockServer { requests: unknown[] /** Header bags of received requests, in order (parallel to `requests`). */ headers: IncomingMessage['headers'][] + /** Parsed Files API operations, excluded from chat request ordering. */ + fileRequests: Array<{ method: string; path: string; filename?: string; bytes?: number }> script: Behavior[] close(): Promise } @@ -36,36 +38,111 @@ export const textEvents = [ export async function mockServer(script: Behavior[]): Promise { const requests: unknown[] = [] const headers: IncomingMessage['headers'][] = [] + const fileRequests: MockServer['fileRequests'] = [] + const files = new Map() + let nextFile = 1 const server = createServer((request: IncomingMessage, response: ServerResponse) => { - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) request.on('end', () => { - requests.push(JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() - if (!behavior) { - response.writeHead(500).end('mock script exhausted') - return - } - if (behavior.kind === 'http-error') { - response.writeHead(behavior.status, { - 'content-type': behavior.contentType ?? 'application/json', - ...behavior.headers, - }) - response.end(behavior.body) - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - const write = (index: number): void => { - if (index >= behavior.events.length) { - if (behavior.kind === 'sse') response.end() - else response.destroy() // close-early: drop the socket mid-stream + void (async () => { + const url = new URL(request.url ?? '/', 'http://localhost') + const body = Buffer.concat(chunks) + if (url.pathname === '/files' && request.method === 'POST') { + const headers = new Headers() + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value) + } + const form = await new Request('http://localhost/files', { + method: 'POST', + headers, + body, + }).formData() + const blob = form.get('file') + if (!(blob instanceof Blob)) throw new Error('mock upload omitted file') + const name = 'name' in blob && typeof blob.name === 'string' ? blob.name : 'uploaded_file' + const id = `file-api-${nextFile}` + const createdAt = Math.floor(Date.now() / 1_000) + nextFile += 1 + const expiresSeconds = Number(form.get('expires_after[seconds]')) + const file = { + id, + object: 'file' as const, + bytes: blob.size, + created_at: createdAt, + filename: name, + purpose: 'user_data' as const, + expires_at: createdAt + expiresSeconds, + } + files.set(id, file) + fileRequests.push({ method: 'POST', path: url.pathname, filename: name, bytes: blob.size }) + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(file)) return } - response.write(`data: ${behavior.events[index]}\n\n`) - setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) - } - write(0) + if (url.pathname === '/files' && request.method === 'GET') { + fileRequests.push({ method: 'GET', path: `${url.pathname}${url.search}` }) + const data = [...files.values()] + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ + object: 'list', + data, + first_id: data[0]?.id, + last_id: data.at(-1)?.id, + has_more: false, + })) + return + } + if (url.pathname.startsWith('/files/') && request.method === 'DELETE') { + const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + files.delete(id) + fileRequests.push({ method: 'DELETE', path: url.pathname }) + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ + id, object: 'file', deleted: true, + })) + return + } + if (url.pathname.startsWith('/files/') && request.method === 'GET') { + const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + fileRequests.push({ method: 'GET', path: url.pathname }) + const file = files.get(id) + if (file === undefined) { + response.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ + error: { message: 'file not found', code: 'file_not_found' }, + })) + } else { + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(file)) + } + return + } + + requests.push(JSON.parse(body.toString('utf8'))) + headers.push(request.headers) + const behavior = script.shift() + if (!behavior) { + response.writeHead(500).end('mock script exhausted') + return + } + if (behavior.kind === 'http-error') { + response.writeHead(behavior.status, { + 'content-type': behavior.contentType ?? 'application/json', + ...behavior.headers, + }) + response.end(behavior.body) + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + const write = (index: number): void => { + if (index >= behavior.events.length) { + if (behavior.kind === 'sse') response.end() + else response.destroy() // close-early: drop the socket mid-stream + return + } + response.write(`data: ${behavior.events[index]}\n\n`) + setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) + } + write(0) + })().catch((error: unknown) => { + response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error)) + }) }) }) servers.push(server) @@ -76,6 +153,7 @@ export async function mockServer(script: Behavior[]): Promise { url: `http://127.0.0.1:${address.port}`, requests, headers, + fileRequests, script, close: () => new Promise(resolve => server.close(() => { resolve() })), } diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 742efc863d..04717da6e0 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { @@ -9,14 +9,21 @@ import { serializeRequest, serializeRequestWithImages, } from '../src/serialize.ts' +import type { ImageSerializationOptions } from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } } function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef { + const digit = ({ + 'image/png': 'a', + 'image/jpeg': 'b', + 'image/webp': 'c', + 'image/gif': 'd', + } as const)[mediaType] return { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + attachmentId: AttachmentId(`sha256:${digit.repeat(64)}`), mediaType, bytes, width: 1, @@ -24,13 +31,36 @@ function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAtta } } -function attachmentStore( - readImage = vi.fn((ref: ImageAttachmentRef, _signal?: AbortSignal) => Promise.resolve({ - ref, - data: Uint8Array.of(1, 2, 3), - })), -): AttachmentStore { - return { readImage } as unknown as AttachmentStore +function fileResolver(id = 'file-api-image') { + return vi.fn(() => Promise.resolve(id)) +} + +function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { + const hash = String(ref.attachmentId).slice('sha256:'.length) + return { + variantId: ImageVariantId(`sha256:${hash}`), + master: ref, + data: new Uint8Array(ref.bytes), + mediaType: ref.mediaType, + bytes: ref.bytes, + width: ref.width, + height: ref.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: ref.mediaType === 'image/png', + } +} + +function imageOptions( + refs: readonly ImageAttachmentRef[], + resolveFileId: ImageSerializationOptions['resolveFileId'] = fileResolver(), + maxRequestFilesBytes = 20 * 1024 * 1024, +) { + return { + resolveFileId, + requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), + maxRequestFilesBytes, + } } describe('serializeMessages', () => { @@ -304,53 +334,47 @@ describe('image serialization', () => { 'image/webp', 'image/gif', ] as const)('preserves ordered text and %s image parts', async (mediaType) => { - const signal = new AbortController().signal - const readImage = vi.fn((ref: ImageAttachmentRef, received?: AbortSignal) => { - expect(received).toBe(signal) - return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) - }) + const resolveFileId = fileResolver() + const ref = imageRef(mediaType) const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ content: [ { type: 'text', text: 'before' }, - { type: 'image', attachment: imageRef(mediaType) }, + { type: 'image', attachment: ref }, { type: 'text', text: 'after' }, ], source: { kind: 'plugin', plugin: 'test' }, })], - }), { - attachments: attachmentStore(readImage), - maxRequestImageBytes: 20 * 1024 * 1024, - signal, - }) + }), imageOptions([ref], resolveFileId)) expect(wire.messages).toEqual([{ role: 'user', content: [ { type: 'text', text: 'before' }, - { type: 'image_url', image_url: { url: `data:${mediaType};base64,AQID` } }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; preview 1x1px`) as string }, + { type: 'file', file_id: 'file-api-image' }, { type: 'text', text: 'after' }, ], }]) }) - it('serializes image-only user content without synthetic text', async () => { + it('gives image-only input a stable handle and preview coordinate system', async () => { + const ref = imageRef() const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], + content: [{ type: 'image', attachment: ref }], source: { kind: 'plugin', plugin: 'test' }, })], - }), { - attachments: attachmentStore(), - maxRequestImageBytes: 20 * 1024 * 1024, - signal: new AbortController().signal, - }) + }), imageOptions([ref])) expect(wire.messages).toEqual([{ role: 'user', - content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }], + content: [ + { type: 'text', text: expect.stringContaining('Call read_image_region') as string }, + { type: 'file', file_id: 'file-api-image' }, + ], }]) }) @@ -377,19 +401,28 @@ describe('image serialization', () => { }), ] - await expect(serializeMessagesWithImages( - messages, - attachmentStore(), - new AbortController().signal, - )).resolves.toEqual([ - { role: 'tool', tool_call_id: 'first', content: '(see attached image)' }, - { role: 'tool', tool_call_id: 'second', content: 'caption' }, + const png = imageRef() + const jpeg = imageRef('image/jpeg') + await expect(serializeMessagesWithImages(messages, imageOptions( + [png, jpeg], + vi.fn((version: RequestImageAttachment) => Promise.resolve(`file-api-${version.mediaType}`)), + ))).resolves.toEqual([ + { + role: 'tool', + tool_call_id: 'first', + content: expect.stringContaining(`Image ${png.attachmentId}`) as string, + }, + { + role: 'tool', + tool_call_id: 'second', + content: expect.stringContaining(`caption\nImage ${jpeg.attachmentId}`) as string, + }, { role: 'user', content: [ { type: 'text', text: 'Attached image(s) from tool result:' }, - { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, - { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + { type: 'file', file_id: 'file-api-image/png' }, + { type: 'file', file_id: 'file-api-image/jpeg' }, ], }, ]) @@ -409,11 +442,7 @@ describe('image serialization', () => { source: { kind: 'plugin', plugin: 'test' }, })] - await expect(serializeMessagesWithImages( - messages, - attachmentStore(), - new AbortController().signal, - )).resolves.toEqual([ + await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ { role: 'tool', tool_call_id: 'result', content: 'ok' }, ]) }) @@ -435,11 +464,7 @@ describe('image serialization', () => { source: { kind: 'plugin', plugin: 'test' }, })] - await expect(serializeMessagesWithImages( - messages, - attachmentStore(), - new AbortController().signal, - )).resolves.toEqual([ + await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ { role: 'tool', tool_call_id: 'nested', content: 'inside' }, { role: 'tool', tool_call_id: 'empty', content: '(no output)' }, ]) @@ -469,113 +494,106 @@ describe('image serialization', () => { }), ] - const wire = await serializeMessagesWithImages( - messages, - attachmentStore(), - new AbortController().signal, - ) + const wire = await serializeMessagesWithImages(messages, imageOptions([imageRef()], fileResolver())) expect(wire).toEqual([ - { role: 'tool', tool_call_id: 'before-system', content: '(see attached image)' }, + { + role: 'tool', + tool_call_id: 'before-system', + content: expect.stringContaining('Call read_image_region') as string, + }, expect.objectContaining({ role: 'user' }), { role: 'system', content: 'system history' }, - { role: 'tool', tool_call_id: 'before-assistant', content: '(see attached image)' }, + { + role: 'tool', + tool_call_id: 'before-assistant', + content: expect.stringContaining('Call read_image_region') as string, + }, expect.objectContaining({ role: 'user' }), { role: 'assistant', content: 'assistant history' }, ]) }) it('offloads oldest images before reads and keeps the newest image', async () => { - const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ - ref, - data: Uint8Array.of(1, 2, 3), - })) + const resolveFileId = fileResolver() + const png = imageRef('image/png', 3) + const jpeg = imageRef('image/jpeg', 3) const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ content: [ - { type: 'image', attachment: imageRef('image/png', 3) }, - { type: 'image', attachment: imageRef('image/jpeg', 3) }, + { type: 'image', attachment: png }, + { type: 'image', attachment: jpeg }, ], source: { kind: 'plugin', plugin: 'test' }, })], - }), { - attachments: attachmentStore(readImage), - maxRequestImageBytes: 4, - signal: new AbortController().signal, - }) + }), imageOptions([png, jpeg], resolveFileId, 4)) expect(wire.messages[0]).toMatchObject({ role: 'user', content: [ { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, - { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + { type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string }, + { type: 'file', file_id: 'file-api-image' }, ], }) - expect(readImage).toHaveBeenCalledTimes(1) - expect(readImage.mock.calls[0]?.[0]).toMatchObject({ mediaType: 'image/jpeg' }) + expect(resolveFileId).toHaveBeenCalledTimes(1) + expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ master: { mediaType: 'image/jpeg' } }) }) it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { - const readImage = vi.fn() + const resolveFileId = vi.fn() await expect(serializeMessagesWithImages([createMessage({ role, content: [{ type: 'image', attachment: imageRef() }], source: { kind: 'plugin', plugin: 'test' }, - })], attachmentStore(readImage), new AbortController().signal)) + })], imageOptions([imageRef()], resolveFileId))) .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(readImage).not.toHaveBeenCalled() + expect(resolveFileId).not.toHaveBeenCalled() }) it('rejects unsupported image history before request offloading can replace it', async () => { - const readImage = vi.fn() + const resolveFileId = vi.fn() await expect(serializeRequestWithImages(request({ messages: [createMessage({ role: 'system', content: [{ type: 'image', attachment: imageRef('image/png', 300) }], source: { kind: 'plugin', plugin: 'test' }, })], - }), { - attachments: attachmentStore(readImage), - maxRequestImageBytes: 1, - signal: new AbortController().signal, - })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(readImage).not.toHaveBeenCalled() + }), imageOptions([imageRef('image/png', 300)], resolveFileId, 1))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveFileId).not.toHaveBeenCalled() }) it('prepends the request system prompt on the image path', async () => { + const ref = imageRef() const wire = await serializeRequestWithImages(request({ system: 'system prompt', messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], + content: [{ type: 'image', attachment: ref }], source: { kind: 'plugin', plugin: 'test' }, })], - }), { - attachments: attachmentStore(), - maxRequestImageBytes: 20 * 1024 * 1024, - signal: new AbortController().signal, - }) + }), imageOptions([ref])) expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' }) }) - it('preserves stable attachment failure codes', async () => { - const readImage = vi.fn(() => Promise.reject(new AttachmentError( - 'Stored attachment bytes are corrupt.', - 'ATTACHMENT_CORRUPT', - ))) + it('preserves stable file-resolution failure codes', async () => { + const failure = new Error('Stored attachment bytes are corrupt.') as Error & { code: string } + failure.code = 'ATTACHMENT_CORRUPT' + const resolveFileId = vi.fn(() => Promise.reject(failure)) await expect(serializeMessagesWithImages([createUserMessage({ content: [{ type: 'image', attachment: imageRef() }], source: { kind: 'plugin', plugin: 'test' }, - })], attachmentStore(readImage), new AbortController().signal)) + })], imageOptions([imageRef()], resolveFileId))) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) }) it('preserves non-attachment resolver failures', async () => { const failure = new Error('resolver failed') - const readImage = vi.fn(() => Promise.reject(failure)) + const resolveFileId = vi.fn(() => Promise.reject(failure)) await expect(serializeMessagesWithImages([createUserMessage({ content: [{ type: 'image', attachment: imageRef() }], source: { kind: 'plugin', plugin: 'test' }, - })], attachmentStore(readImage), new AbortController().signal)).rejects.toBe(failure) + })], imageOptions([imageRef()], resolveFileId))).rejects.toBe(failure) }) }) diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts new file mode 100644 index 0000000000..6157adc06e --- /dev/null +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -0,0 +1,73 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import { DeepSeekFileId } from '../src/file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' + +const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`) +const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`) + +describe('DeepSeekUploadIndex', () => { + it('isolates API-key namespaces and reuses only records above the refresh margin', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + const first = deepSeekFileScope('https://api.deepseek.com', 'first-key') + const second = deepSeekFileScope('https://api.deepseek.com', 'second-key') + const record = { + scope: first, + masterAttachmentId: ATTACHMENT, + variantId: VARIANT, + fileId: DeepSeekFileId('file-api-one'), + bytes: 3, + createdAt: 1_000, + expiresAt: 10_000, + } + + await expect(index.commit(record, 1_000, 1_000)).resolves.toMatchObject({ accepted: true }) + await expect(index.get(first, VARIANT, 1_000, 1_000)).resolves.toEqual(record) + await expect(index.get(second, VARIANT, 1_000, 1_000)).resolves.toBeUndefined() + await expect(index.get(first, VARIANT, 9_000, 1_000)).resolves.toBeUndefined() + }) + + it('keeps a reusable cross-process winner and removes only an exact generation', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + const scope = deepSeekFileScope('https://api.deepseek.com', 'key') + const first = { + scope, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: DeepSeekFileId('file-api-first'), bytes: 3, createdAt: 1, expiresAt: 10_000, + } + const duplicate = { ...first, fileId: DeepSeekFileId('file-api-duplicate') } + await index.commit(first, 1, 1) + + await expect(index.commit(duplicate, 2, 1)).resolves.toEqual({ record: first, accepted: false }) + await index.remove(scope, VARIANT, duplicate.fileId) + await expect(index.get(scope, VARIANT, 2, 1)).resolves.toEqual(first) + await index.remove(scope, VARIANT, first.fileId) + await expect(index.get(scope, VARIANT, 2, 1)).resolves.toBeUndefined() + }) + + it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + const path = join(dir, 'index.json') + await writeFile(path, '{bad', 'utf8') + const index = new DeepSeekUploadIndex(path) + const scope = deepSeekFileScope('https://api.deepseek.com', 'key') + const record = { + scope, + masterAttachmentId: ATTACHMENT, + variantId: VARIANT, + fileId: DeepSeekFileId('file-api-repaired'), + bytes: 3, + createdAt: 1, + expiresAt: 10_000, + } + + await expect(index.get(scope, VARIANT, 1, 1)).resolves.toBeUndefined() + await expect(index.commit(record, 1, 1)).resolves.toEqual({ record, accepted: true }) + await expect(index.get(scope, VARIANT, 1, 1)).resolves.toEqual(record) + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ formatVersion: 2 }) + }) +}) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 0a1751aab1..0ba1a2116c 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -20,6 +20,18 @@ { "path": "../../llm/llm" }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../util/atomic-write" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/home-paths" + }, { "path": "../../credentials/credentials" }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 034382822f..b951aad76e 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.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 packages/llm/llm-pi-ai/README.md -README.md: 19dbcfa90dbefbe322800c83da6d75c70c849f05 -README.zh.md: 334c6c3166f35f5dc7d7659b9de7cff404c6b56d +README.md: 044038aa69535ad90c9dc59ad63f05ab68560d28 +README.zh.md: d4b5dff10ea0f3668038cc4d3a6876f52ae273cb diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 19dbcfa90d..044038aa69 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -20,6 +20,9 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high + requestImagePixelBudget: 4194304 # total pixels; 2048 by 2048 default + requestImageMaxBytes: 1048576 # raw bytes before base64 expansion + maxRequestImageBytes: 20971520 # accumulated base64 payload retryPolicy: mode: normal maxRetries: 3 @@ -120,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route first derives a deterministic request version from the provider-independent master under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual preview dimensions. `maxRequestImageBytes` then bounds the accumulated base64 length (default 20MiB): the oldest request versions are replaced by a fixed text placeholder until the request fits. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -170,11 +173,11 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose, with one exception: when a request's accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text. The text tells the model to read the file again when a path is available or ask the user to attach the image again. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id, actual request dimensions, and `read_image_region` preview coordinates. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect -Provider tokenization governs exact input. Conversion adds no model-visible text beyond the image-offload placeholder, which replaces the offloaded image's visual tokens with a short fixed sentence; replay metadata may let a native API reuse provider-side state. +Provider tokenization governs exact input. Retained images add the stable attachment and coordinate descriptor; the offload placeholder replaces an omitted image's visual tokens. Replay metadata may let a native API reuse provider-side state. #### KV Cache effect @@ -196,7 +199,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work -- **`maxRequestImageBytes` counts base64 image payload only** — text, tools, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is decided at request conversion as a pure function of history and configuration and is not recorded as a session event; per-route capability metadata (image count, per-image size, total request size) driving admission and assembly together is deferred design work. +- **`maxRequestImageBytes` counts base64 image payload only** — text, tools, descriptors, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is a deterministic request projection and is not recorded as a session event. - **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer. - **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam. - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. @@ -204,7 +207,7 @@ Recorded response content appends to the next request and does not invalidate it - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. -- **A modality declaration is not verified, and over-claiming outlives the turn** — nothing interrogates an endpoint for what it accepts, so a model declaring `image` its gateway does not serve is refused by the provider mid-turn rather than here. Prompt admission commits the user message durably before the request is built, so the rejected image stays in the session log: that model keeps re-sending it, and model selection refuses a switch to any text-only model. Recovery is another image-capable model, a fork before the image, or a new session; rolling an unconsumed image message back out of the log on a failed send is deferred. +- **A modality declaration is not verified** — nothing interrogates an endpoint for what it accepts, so a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again. Switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that exact request. - **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 334c6c3166..d4b5dff10e 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -20,6 +20,9 @@ apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high + requestImagePixelBudget: 4194304 # total pixels; 2048 by 2048 default + requestImageMaxBytes: 1048576 # raw bytes before base64 expansion + maxRequestImageBytes: 20971520 # accumulated base64 payload retryPolicy: mode: normal maxRetries: 3 @@ -121,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由先从提供方无关的主版本派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际预览尺寸。`maxRequestImageBytes` 再限制累计 base64 长度(默认 20MiB);超出时从最旧请求版本开始替换为固定文本占位,直到请求可容纳。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -171,11 +174,11 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。本包不添加提示词文本,仅有一个例外:请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片(从最老开始)会被替换为一段固定文本。该文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸和 `read_image_region` 使用的预览坐标。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 -精确输入取决于提供方 tokenization。除图片 offload 占位文本外,转换不添加模型可见文本;占位文本用一句固定短句替代被省略图片的视觉 token。回放元数据可能让原生 API 复用提供方侧状态。 +精确输入取决于提供方 tokenization。保留图片会增加稳定的附件与坐标描述;offload 占位文本替代被省略图片的视觉 token。回放元数据可能让原生 API 复用提供方侧状态。 #### KV Cache 影响 @@ -197,7 +200,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 -- **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具与 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 在请求转换时决定,是历史与配置的纯函数,不记录为会话事件;由按路由能力元数据(图片数量、单图大小、请求总大小)同时驱动准入与组装的完整设计属于暂缓工作。 +- **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具、图片描述和 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 是确定性请求投影,不记录为会话事件。 - **一次登录只存活于发起它的进程中**:授权尝试不可持久,登录途中刷新页面会丢弃它,人需要重来。登出即对已存储记录执行 `deleteRecord`,它只在本地遗忘而不通知签发方。 - **提供方自带的凭据发现经由本插件的 ambient context 作答**:不指定凭据的路由交由 catalog 提供方自行解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 以及各提供方自己的那一组)与本地凭据文件是否存在。两类问题都在这里作答:先查凭据 seam 再查进程环境,文件存在性则按宿主进程的文件系统判断并展开 `~`。它做不到的是*读取*凭据文件的内容——自行解析 `~/.aws/credentials` 的提供方是直接读盘的,不经过 seam。 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 @@ -205,7 +208,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.zh.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 -- **模态声明不经验证,且多声明的后果超出本轮**:没有任何环节会去询问端点接受什么,因此声明了网关并不提供的 `image` 的模型不会在这里被拦下,而是由提供方在轮次中途拒绝。prompt 准入在构造请求之前就把用户消息持久化提交,于是被拒绝的图片留在会话日志里:该模型会不断重发它,而模型选择拒绝切换到任何纯文本模型。恢复途径是换一个确实支持图片的模型、fork 到图片之前,或开启新会话;发送失败时把尚未消费的图片消息从日志中回滚出去这件事已暂缓。 +- **模态声明不经验证**:没有任何环节会去询问端点接受什么,因此声明了网关并不提供的 `image` 的模型会在 prompt 准入后被提供方拒绝。持久图片会留在历史中,同一个错误声明的模型可能再次失败。系统仍允许切换到纯文本模型,因为共享 LLM 运行时会在该次请求中把图片引用投影为稳定文本。 - **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个由 `apiKeyEnv` 引用的占位凭据,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 3c7ecd4a91..c5af6bff6a 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -50,6 +50,7 @@ import type { LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, + PreparedAdapterCall, ReasoningEffortId as ReasoningEffortIdType, ResolvedRetryPolicy, StreamChunk, @@ -284,25 +285,44 @@ export class PiAiAdapter extends LlmAdapter { ): Promise { return Promise.resolve().then(() => { const snapshot = this.current() - const profile = this.profileOf(snapshot, provider) - const resolvedModel = this.modelOf(snapshot, provider, model) - const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning) - // Only a cap the deployment configured is a request default; the - // catalog's `maxTokens` sizes the model and stops there. - const configuredMaxTokens = profile.configuredMaxTokens.get(model) - return { - provider, - id: model, - name: resolvedModel.name, - inputModalities: [...resolvedModel.input], - context: { contextWindow: resolvedModel.contextWindow }, - ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, - ...reasoningInfo(resolvedModel, defaultLevel), - } + return this.modelInfo(snapshot, provider, model) }) } - async * stream(options: GenerateOptions): AsyncIterable { + private modelInfo(snapshot: PiAiSnapshot, provider: string, model: string): LlmResolvedModelInfo { + const profile = this.profileOf(snapshot, provider) + const resolvedModel = this.modelOf(snapshot, provider, model) + const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning) + // Only a cap the deployment configured is a request default; the + // catalog's `maxTokens` sizes the model and stops there. + const configuredMaxTokens = profile.configuredMaxTokens.get(model) + return { + provider, + id: model, + name: resolvedModel.name, + inputModalities: [...resolvedModel.input], + context: { contextWindow: resolvedModel.contextWindow }, + ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, + ...reasoningInfo(resolvedModel, defaultLevel), + } + } + + override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { + const snapshot = this.current() + return Promise.resolve({ + model: this.modelInfo(snapshot, provider, model), + stream: options => this.streamWithSnapshot(options, snapshot), + }) + } + + stream(options: GenerateOptions): AsyncIterable { + return this.streamWithSnapshot(options, this.current()) + } + + private async * streamWithSnapshot( + options: GenerateOptions, + snapshot: PiAiSnapshot, + ): AsyncIterable { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } @@ -311,7 +331,6 @@ export class PiAiAdapter extends LlmAdapter { // snapshot, and the credential freezes with them. A configuration change // mid-request builds a separate snapshot, so this request finishes under // the one it started with and the next call picks up the new one. - const snapshot = this.current() const profile = this.profileOf(snapshot, options.provider) const model = this.modelOf(snapshot, options.provider, options.model) const reasoning = resolveReasoningLevel( @@ -341,7 +360,10 @@ export class PiAiAdapter extends LlmAdapter { } const context = attachments === undefined ? toPiContext(options, undefined, onReplayDegrade) - : await toPiContext(options, attachments, onReplayDegrade, profile.maxRequestImageBytes) + : await toPiContext(options, attachments, onReplayDegrade, profile.maxRequestImageBytes, { + maxPixels: profile.requestImagePixelBudget, + maxBytes: profile.requestImageMaxBytes, + }) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 62d49a58ee..2fd68d4648 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -52,6 +52,10 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 * Deployments behind stricter gateways lower it per route. */ export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 +/** Default total-pixel budget preserves the complete 2048px local master. */ +export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048 +/** Default raw encoded-byte cap before inline base64 expansion. */ +export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Context capacity assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_CONTEXT_WINDOW = 262_144 @@ -163,6 +167,10 @@ export interface PiAiProviderProfile { * requests instead of being rejected by a request-size cap. */ maxRequestImageBytes?: number + /** Total-pixel budget for each deterministic inline request version. */ + requestImagePixelBudget?: number + /** Raw encoded-byte cap for each deterministic inline request version. */ + requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -180,6 +188,10 @@ export interface ResolvedPiAiProviderProfile streamIdleTimeoutMs: number /** Positive request-level base64 image payload bound after defaulting. */ maxRequestImageBytes: number + /** Positive total-pixel request-version budget after defaulting. */ + requestImagePixelBudget: number + /** Positive raw request-version byte cap after defaulting. */ + requestImageMaxBytes: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy /** @@ -312,6 +324,8 @@ const profile = z.object({ websocketConnectTimeoutMs: z.natural(), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES), + requestImagePixelBudget: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET), + requestImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_MAX_BYTES), retryPolicy: RetryPolicySchema, }) @@ -391,6 +405,14 @@ export function resolveProfiles( if (!Number.isInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) { throw new Error(`llm-pi-ai: provider "${provider}" maxRequestImageBytes must be a positive integer`) } + const requestImagePixelBudget = source.requestImagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET + if (!Number.isSafeInteger(requestImagePixelBudget) || requestImagePixelBudget <= 0) { + throw new Error(`llm-pi-ai: provider "${provider}" requestImagePixelBudget must be a positive safe integer`) + } + const requestImageMaxBytes = source.requestImageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES + if (!Number.isSafeInteger(requestImageMaxBytes) || requestImageMaxBytes <= 0) { + throw new Error(`llm-pi-ai: provider "${provider}" requestImageMaxBytes must be a positive safe integer`) + } // Detached from the configuration object because pi-ai types `Model.input` // mutable. The schema's explicit default covers an absent key, so an empty // list here is always one someone typed — and unlike an entry's, nothing @@ -423,6 +445,8 @@ export function resolveProfiles( ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, maxRequestImageBytes, + requestImagePixelBudget, + requestImageMaxBytes, retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index d66a48115d..5cf9b7c042 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,11 +4,18 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImagePreviewText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentId, + AttachmentStore, + ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, +} from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' +import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts' /** Join the text blocks of a harness message. */ function flattenText(message: Message): string { @@ -40,7 +47,7 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { async function userContent( blocks: readonly ContentBlock[], - attachments: AttachmentStore, + requestImages: ReadonlyMap, ): Promise { const content: (TextContent | ImageContent)[] = [] for (const block of blocks) { @@ -49,17 +56,21 @@ async function userContent( if (block.text.length > 0) content.push({ type: 'text', text: block.text }) break case 'image': { - const stored = await attachments.readImage(block.attachment) + const version = requestImages.get(block.attachment.attachmentId) + if (version === undefined) { + throw new LlmError(`pi-ai request image ${block.attachment.attachmentId} was not prepared`, 'INVALID_REQUEST') + } + content.push({ type: 'text', text: requestImagePreviewText(version) }) content.push({ type: 'image', - data: Buffer.from(stored.data).toString('base64'), - mimeType: stored.ref.mediaType, + data: Buffer.from(version.data).toString('base64'), + mimeType: version.mediaType, }) break } case 'tool-result': { - const nested = await userContent(block.content, attachments) + const nested = await userContent(block.content, requestImages) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -76,6 +87,28 @@ async function userContent( return content } +function collectImageRefs( + blocks: readonly ContentBlock[], + refs: Map, +): void { + for (const block of blocks) { + if (block.type === 'image') refs.set(block.attachment.attachmentId, block.attachment) + else if (block.type === 'tool-result') collectImageRefs(block.content, refs) + } +} + +async function prepareRequestImages( + messages: readonly Message[], + attachments: AttachmentStore, + policy: ImageRequestPolicy, +): Promise> { + const refs = new Map() + for (const message of messages) collectImageRefs(message.content, refs) + const versions = new Map() + for (const [id, ref] of refs) versions.set(id, await attachments.readImageRequest(ref, policy)) + return versions +} + function toolsOf(options: GenerateOptions): PiTool[] | undefined { return options.tools?.map(tool => ({ name: tool.name, @@ -156,6 +189,7 @@ export function toPiContext( * @param attachments - durable byte resolver for image references. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @param maxRequestImageBytes - request-level bound on base64-encoded image payload; omission leaves every image in place. + * @param requestImagePolicy - route pixel and raw encoded-byte budgets. * @returns the asynchronously resolved pi-ai context. */ export function toPiContext( @@ -163,16 +197,18 @@ export function toPiContext( attachments: AttachmentStore, onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, + requestImagePolicy?: ImageRequestPolicy, ): Promise export function toPiContext( options: GenerateOptions, attachments?: AttachmentStore, onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, + requestImagePolicy?: ImageRequestPolicy, ): PiContext | Promise { return attachments === undefined ? textOnlyContext(options, onReplayDegrade) - : toPiContextWithImages(options, attachments, onReplayDegrade, maxRequestImageBytes) + : toPiContextWithImages(options, attachments, onReplayDegrade, maxRequestImageBytes, requestImagePolicy) } async function toPiContextWithImages( @@ -180,9 +216,19 @@ async function toPiContextWithImages( attachments: AttachmentStore, onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, + requestImagePolicy: ImageRequestPolicy = { + maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, + maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, + }, ): Promise { assertSupportedImageRoles(options.messages) - const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes) + const requestImages = await prepareRequestImages(options.messages, attachments, requestImagePolicy) + const requestMessages = offloadRequestImagesWithPolicy(options.messages, { + representation: 'base64', + ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, + byteQuantum: 1, + byteLength: ref => requestImages.get(ref.attachmentId)?.bytes ?? ref.bytes, + }) const toolNames = new Map() const messages: PiMessage[] = [] @@ -204,7 +250,7 @@ async function toPiContextWithImages( } // user role: text + tool results (each result becomes its own message). const regular = message.content.filter(block => block.type !== 'tool-result') - const content = await userContent(regular, attachments) + const content = await userContent(regular, requestImages) const results = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) @@ -212,7 +258,7 @@ async function toPiContextWithImages( messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { - const resultContent = await userContent(result.content, attachments) + const resultContent = await userContent(result.content, requestImages) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index c7336cb8d7..171f898db9 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -76,6 +78,29 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/chat/completions']) }) + it('keeps prepared model metadata and dispatch on one profile snapshot', async () => { + const first = await mockServer([{ events: textEvents }]) + const second = await mockServer([]) + let providers: Record = { + deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: first.url }, + } + const ctx = new Context() + await ctx.plugin(LlmRuntime) + ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({ + profiles: () => resolveProfiles(providers), + resolveApiKey: () => Promise.resolve('test-key'), + })) + + const prepared = await ctx.llm.prepareCall({ provider: 'deepseek', model: 'deepseek-v4-flash' }) + providers = { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: second.url } } + const chunks: unknown[] = [] + for await (const chunk of prepared.stream({ ...prepared.config, messages: [] })) chunks.push(chunk) + + expect(chunks.length).toBeGreaterThan(0) + expect(first.requests).toHaveLength(1) + expect(second.requests).toHaveLength(0) + }) + it('merges profile headers with Harness attribution winning', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { @@ -213,6 +238,20 @@ describe('PiAiAdapter provider routing', () => { } const readImage = vi.fn((_ref: ImageAttachmentRef): Promise => Promise.resolve({ ref, data: Uint8Array.of(1) })) + const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy): Promise => ( + Promise.resolve({ + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + master: value, + data: Uint8Array.of(1), + mediaType: value.mediaType, + bytes: 1, + width: value.width, + height: value.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: true, + }) + )) class LateAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { @@ -235,6 +274,10 @@ describe('PiAiAdapter provider routing', () => { readImage(value: ImageAttachmentRef): Promise { return readImage(value) } + + override readImageRequest(value: ImageAttachmentRef, policy: ImageRequestPolicy): Promise { + return readImageRequest(value, policy) + } } const ctx = new Context() @@ -254,7 +297,10 @@ describe('PiAiAdapter provider routing', () => { }) expect(result.finish.kind).toBe('error') - expect(readImage).toHaveBeenCalledWith(ref) + expect(readImageRequest).toHaveBeenCalledWith(ref, { + maxPixels: 2048 * 2048, + maxBytes: 1024 * 1024, + }) expect(server.paths).toEqual(['/v1/responses']) }) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index b82c6b63f5..7163a7375d 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { toPiContext } from '../src/context.ts' @@ -14,9 +14,30 @@ const ref: ImageAttachmentRef = { height: 1, } -const attachments = { - readImage: vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1) })), -} as unknown as AttachmentStore +function requestImage(value: ImageAttachmentRef, data: Uint8Array): RequestImageAttachment { + return { + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + master: value, + data, + mediaType: value.mediaType, + bytes: data.byteLength, + width: value.width, + height: value.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: value.mediaType === 'image/png', + } +} + +function projectionStore( + readImageRequest = vi.fn((value: ImageAttachmentRef) => ( + Promise.resolve(requestImage(value, Uint8Array.of(1))) + )), +): AttachmentStore { + return { readImageRequest } as unknown as AttachmentStore +} + +const attachments = projectionStore() function request(messages: GenerateOptions['messages']): GenerateOptions { return { @@ -116,6 +137,7 @@ describe('pi-ai request context conversion', () => { { role: 'user', content: [ + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}`) as string }, { type: 'image', data: 'AQ==', mimeType: 'image/png' }, { type: 'text', text: 'caption' }, ], @@ -133,7 +155,10 @@ describe('pi-ai request context conversion', () => { role: 'toolResult', toolCallId: 'missing-call', toolName: 'unknown', - content: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + content: [ + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}`) as string }, + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + ], isError: true, timestamp: 0, }, @@ -165,6 +190,7 @@ describe('pi-ai request context conversion', () => { toolName: 'unknown', content: [ { type: 'text', text: 'nested text' }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}`) as string }, { type: 'image', data: 'AQ==', mimeType: 'image/png' }, ], isError: false, @@ -194,8 +220,10 @@ describe('pi-ai request context conversion', () => { }) it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => { - const readImage = vi.fn(() => Promise.resolve({ ref: { ...ref, bytes: 3 }, data: Uint8Array.of(1, 2, 3) })) - const store = { readImage } as unknown as AttachmentStore + const readImageRequest = vi.fn((value: ImageAttachmentRef) => ( + Promise.resolve(requestImage(value, Uint8Array.of(1, 2, 3))) + )) + const store = projectionStore(readImageRequest) const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const callId = CallId('shot-call') // Three 3-byte images cost 4 base64 characters each (12 total); a bound of @@ -222,14 +250,22 @@ describe('pi-ai request context conversion', () => { { role: 'user', content: [ + { type: 'text', text: expect.stringContaining(`Image ${sized.attachmentId}`) as string }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, { type: 'text', text: 'newer' }, ], timestamp: 0, }, - { role: 'user', content: [{ type: 'image', data: 'AQID', mimeType: 'image/png' }], timestamp: 0 }, + { + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining(`Image ${sized.attachmentId}`) as string }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ], + timestamp: 0, + }, ]) - expect(readImage).toHaveBeenCalledTimes(2) + expect(readImageRequest).toHaveBeenCalledTimes(1) }) it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { @@ -239,12 +275,22 @@ describe('pi-ai request context conversion', () => { user([{ type: 'image', attachment: sized }]), ]), attachments, undefined, 8) expect(exact.messages).toEqual([ - { role: 'user', content: [expect.objectContaining({ type: 'image' })], timestamp: 0 }, - { role: 'user', content: [expect.objectContaining({ type: 'image' })], timestamp: 0 }, + { + role: 'user', + content: [expect.objectContaining({ type: 'text' }), expect.objectContaining({ type: 'image' })], + timestamp: 0, + }, + { + role: 'user', + content: [expect.objectContaining({ type: 'text' }), expect.objectContaining({ type: 'image' })], + timestamp: 0, + }, ]) - const readImage = vi.fn() - const store = { readImage } as unknown as AttachmentStore + const readImageRequest = vi.fn((value: ImageAttachmentRef) => ( + Promise.resolve(requestImage(value, new Uint8Array(300))) + )) + const store = projectionStore(readImageRequest) const oversized = await toPiContext(request([ user([{ type: 'image', attachment: { ...ref, bytes: 300 } }]), ]), store, undefined, 8) @@ -252,14 +298,16 @@ describe('pi-ai request context conversion', () => { expect(oversized.messages).toEqual([ { role: 'user', content: OFFLOADED_IMAGE_TEXT, timestamp: 0 }, ]) - expect(readImage).not.toHaveBeenCalled() + expect(readImageRequest).toHaveBeenCalledTimes(1) }) it('offloads repeated image-block occurrences by position rather than shared object identity', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const shared: ContentBlock = { type: 'image', attachment: sized } - const readImage = vi.fn(() => Promise.resolve({ ref: sized, data: Uint8Array.of(1, 2, 3) })) - const store = { readImage } as unknown as AttachmentStore + const readImageRequest = vi.fn((value: ImageAttachmentRef) => ( + Promise.resolve(requestImage(value, Uint8Array.of(1, 2, 3))) + )) + const store = projectionStore(readImageRequest) const aliased = await toPiContext(request([user([shared, shared])]), store, undefined, 4) const replayed = await toPiContext(request([user([ { type: 'image', attachment: { ...sized } }, @@ -270,13 +318,14 @@ describe('pi-ai request context conversion', () => { role: 'user', content: [ { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: expect.stringContaining(`Image ${sized.attachmentId}`) as string }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, ], timestamp: 0, }] expect(aliased.messages).toEqual(expected) expect(replayed.messages).toEqual(expected) - expect(readImage).toHaveBeenCalledTimes(2) + expect(readImageRequest).toHaveBeenCalledTimes(2) }) it('keeps empty text-only users while separating result-only messages', () => { @@ -303,12 +352,12 @@ describe('pi-ai request context conversion', () => { it('handles in-history system and assistant messages explicitly on the image path', async () => { for (const role of ['system', 'assistant'] as const) { - const readImage = vi.fn() - const store = { readImage } as unknown as AttachmentStore + const readImageRequest = vi.fn() + const store = projectionStore(readImageRequest) await expect(toPiContext(request([ history(role, [{ type: 'image', attachment: ref }]), ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(readImage).not.toHaveBeenCalled() + expect(readImageRequest).not.toHaveBeenCalled() } await expect(toPiContext(request([ diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 2a3b41b0c4..e77075b0c6 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' @@ -43,6 +43,21 @@ async function collect(stream: AsyncIterable): Promise { it('maps system prompt, user text, and tools', () => { const context = toPiContext({ @@ -76,7 +91,7 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve(requestVersion(value))) const context = await toPiContext({ provider: 'openai', model: 'gpt-4.1', @@ -84,13 +99,17 @@ describe('toPiContext', () => { content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], source: { kind: 'plugin', plugin: 'test' }, })], - }, { readImage } as unknown as AttachmentStore) + }, { readImageRequest } as unknown as AttachmentStore) - expect(readImage).toHaveBeenCalledWith(attachment) + expect(readImageRequest).toHaveBeenCalledWith( + attachment, + { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 }, + ) expect(context.messages[0]).toEqual({ role: 'user', content: [ { type: 'text', text: 'describe' }, + { type: 'text', text: expect.stringContaining(`Image ${attachment.attachmentId}`) as string }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, ], timestamp: 0, @@ -105,7 +124,7 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve(requestVersion(value))) const context = await toPiContext({ provider: 'openai', model: 'gpt-4.1', @@ -129,7 +148,7 @@ describe('toPiContext', () => { }], source: { kind: 'plugin', plugin: 'test' }, })], - }, { readImage } as unknown as AttachmentStore) + }, { readImageRequest } as unknown as AttachmentStore) expect(context.messages).toEqual([{ role: 'toolResult', @@ -138,6 +157,7 @@ describe('toPiContext', () => { content: [ { type: 'text', text: 'before' }, { type: 'text', text: 'middle' }, + { type: 'text', text: expect.stringContaining(`Image ${attachment.attachmentId}`) as string }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, { type: 'text', text: 'after' }, ], diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index b0b1dbba9a..93732e75d3 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -1,10 +1,12 @@ import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -89,6 +91,24 @@ async function harness(image?: StoredImageAttachment): Promise { } return Promise.resolve(fixture) } + + override readImageRequest(ref: ImageAttachmentRef, _policy: ImageRequestPolicy): Promise { + if (ref.attachmentId !== fixture.ref.attachmentId) { + return Promise.reject(new Error('unknown e2e attachment fixture')) + } + return Promise.resolve({ + variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), + master: fixture.ref, + data: fixture.data, + mediaType: fixture.ref.mediaType, + bytes: fixture.data.byteLength, + width: fixture.ref.width, + height: fixture.ref.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: fixture.ref.mediaType === 'image/png', + }) + } } await ctx.plugin(E2eAttachmentStore) } diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 2621de89af..08820bbfd9 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.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 packages/llm/llm/README.md -README.md: e6b3c4924ad4e7cf115abdbfb38d22d0f524e377 -README.zh.md: 91f1c6ede2b800671b35a90d02a23a6d79e96e0e +README.md: 59c5303bdae8d6391f3bb1595a527d677e2378bc +README.zh.md: 313fc23590de2119bf07dd5c4c4fafe9daa94c56 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index e6b3c4924a..59c5303bda 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,7 +25,7 @@ Each provider adapter supplies its resolved route policy. Omitting provider conf - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize adapter-configured call defaults without clamping. -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config plus detached context metadata and markers for fields supplied by adapter defaults in one exact-model lookup, then capture its current adapter registration and immutable retry policy as one cancellable, one-shot call. +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config plus detached context and modality metadata and markers for fields supplied by adapter defaults in one exact-model lookup, then capture the adapter's matching dispatch generation and immutable retry policy as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmRuntime` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy. @@ -38,7 +38,7 @@ Every topology commit point — adapter routes registering or disposing, directo Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context`, `defaultMaxTokens`, or `reasoning` fields preserve unknown capacity, provider-owned output defaults, or unavailable reasoning capability. Invalid identity, context, output default, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, `INVALID_MODEL_MAX_TOKENS`, or `INVALID_MODEL_REASONING`. -`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally exposes detached context metadata from the same lookup, reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults`, and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. +`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally exposes detached context and input-modality metadata, reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults`, and binds those facts to the adapter generation that performs terminal dispatch. HMR or dynamic settings therefore cannot combine one generation's image capability with another generation's endpoint; reusing the one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. ### Events @@ -55,7 +55,9 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o `Message` is the shared immutable value used by delivery, durable history, and model requests. Every message has a required `MessageId`, role, content, and typed source from creation onward. `createMessage(input)` mints the identity and returns a detached deep-frozen value; `createUserMessage({ content, source })` fixes the user role; `createAssistantMessage({ content, source })` fixes the assistant role and model source kind; `createToolResultMessage({ callId, content, isError })` fixes the user role and couples the tool source to its result block; `freezeMessage(message)` imports an identity that already exists and never replaces it. Message rewrites preserve the identity and produce another frozen value. Browser code imports these value constructors from the dependency-minimal `@deepseek-ai/dsh-llm/message` entry instead of the service-bearing package root. -Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it. +Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. + +Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 91f1c6ede2..313fc23590 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,7 +25,7 @@ - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有该精确路由的适配器中,解析并校验确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。 -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 在一次精确模型查询中解析配置、脱耦的上下文元数据以及标明哪些字段由适配器默认值填入的标记,再将当前适配器注册和不可变重试策略捕获为一次可取消、一次性调用。 +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 在一次精确模型查询中解析配置、脱耦的上下文与模态元数据以及标明哪些字段由适配器默认值填入的标记,再把适配器匹配的分发世代和不可变重试策略捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。 `LlmRuntime` 将最终适配器选择、同步分发、迭代器构造和迭代期间的失败,统一转换为流协议唯一的终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分增量输出后发生失败时,内容块可能仍未闭合;消费方会丢弃这些不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出,因为它们属于插件或消费方失败,而非模型请求结果。已准备调用会暴露随其确切适配器注册一同捕获的不可变重试策略;完全由 middleware 处理的路由没有服务策略。 @@ -38,7 +38,7 @@ 确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型。缺少 `context` 表示模型容量未知;缺少 `defaultMaxTokens` 表示继续沿用提供方自身的输出默认值;缺少 `reasoning` 则表示推理能力不可用。无效的身份、上下文、输出默认值或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT`、`INVALID_MODEL_MAX_TOKENS` 或 `INVALID_MODEL_REASONING` 失败。 -`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器定义的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方信号,并且必须在取消后尽快结算。`prepareCall()` 还会返回同一次查询得到的、与适配器内部状态分离的上下文元数据,通过 `adapterDefaults` 标明填入了哪些 `maxTokens` 和 `reasoningEffort` 字段,并在请求头记录和最终分发期间始终保留同一项精确的适配器注册。因此,HMR(热模块替换)不会把一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器定义的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方信号,并且必须在取消后尽快结算。`prepareCall()` 还会公开脱离内部状态的上下文和输入模态元数据,通过 `adapterDefaults` 标明填入了哪些 `maxTokens` 和 `reasoningEffort` 字段,并把这些事实绑定到执行最终分发的适配器世代。因此,HMR(热模块替换)或动态 settings 不会把一个世代的图片能力与另一个世代的端点组合;复用一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 ### 事件 @@ -55,7 +55,9 @@ `Message` 是投递、持久历史和模型请求共享的不可变值。每条消息从创建起都必须具有 `MessageId`、角色、内容和带类型的来源。`createMessage(input)` 生成标识,并返回与输入分离且深度冻结的值;`createUserMessage({ content, source })` 固定 user 角色;`createAssistantMessage({ content, source })` 固定 assistant 角色与模型来源类别;`createToolResultMessage({ callId, content, isError })` 固定 user 角色,并将工具来源与其结果块耦合;`freezeMessage(message)` 导入已有标识,绝不将其替换。改写消息时会保留标识,并产生另一个冻结值。浏览器端代码会从依赖最少的 `@deepseek-ai/dsh-llm/message` 入口导入这些值构造函数,而不是从包含服务的包根入口导入。 -消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。 +消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。 + +每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度。 流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 55c0719fb9..96c39f4f9b 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,11 +2,33 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' /** Model-facing stand-in for an image removed to fit a provider request bound. */ export const OFFLOADED_IMAGE_TEXT = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' +/** + * Stable text shown to a model that cannot accept one durable image reference. + * @param ref - durable master reference omitted from the request. + * @returns deterministic text-only placeholder. + */ +export function textOnlyImageText(ref: ImageAttachmentRef): string { + const digest = String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 8) + return `[image omitted because this model accepts text only; attachment sha256:${digest}]` +} + +/** + * Stable model-facing handle and coordinate description for one exact request preview. + * @param version - exact request image shown beside the text. + * @returns attachment handle, preview dimensions, and crop-coordinate guidance. + */ +export function requestImagePreviewText(version: RequestImageAttachment): string { + return `Image ${version.master.attachmentId}; preview ${version.width}x${version.height}px. ` + + 'Crop coordinates use this preview. Call read_image_region with this attachment_id, ' + + `preview_width=${version.width}, preview_height=${version.height}, x, y, width, and height.` +} + /** * True when typed model content contains an image block, walking nested * tool-result content. This is the one recursive image walk shared by every @@ -25,13 +47,34 @@ function base64Length(bytes: number): number { return Math.ceil(bytes / 3) * 4 } -/** Collect base64 payload lengths in request and nested-block order. */ -function collectImageLengths(blocks: readonly ContentBlock[], lengths: number[]): void { +/** Byte accounting and quantized removal policy for one request representation. */ +export interface RequestImageOffloadPolicy { + /** Image count accepted by the route; omission leaves count unbounded. */ + maxImages?: number + /** Accumulated image bytes accepted by the route; omission leaves bytes unbounded. */ + maxBytes?: number + /** Number of excess images removed as one deterministic step. */ + countQuantum?: number + /** Number of excess bytes removed as one deterministic step. */ + byteQuantum?: number + /** Whether byte accounting uses raw file bytes or inline base64 length. */ + representation: 'raw' | 'base64' + /** Resolve the encoded request-version length; omission uses master attachment bytes. */ + byteLength?: (ref: ImageAttachmentRef) => number +} + +/** Collect represented image lengths in request and nested-block order. */ +function collectImageLengths( + blocks: readonly ContentBlock[], + lengths: number[], + policy: RequestImageOffloadPolicy, +): void { for (const block of blocks) { if (block.type === 'image') { - lengths.push(base64Length(block.attachment.bytes)) + const bytes = policy.byteLength?.(block.attachment) ?? block.attachment.bytes + lengths.push(policy.representation === 'base64' ? base64Length(bytes) : bytes) } else if (block.type === 'tool-result') { - collectImageLengths(block.content, lengths) + collectImageLengths(block.content, lengths, policy) } } } @@ -62,6 +105,41 @@ function replaceOldestImages( return next ?? blocks as ContentBlock[] } +/** Replace every image occurrence, including nested tool results, for a text-only model. */ +function replaceImagesForTextModel(blocks: readonly ContentBlock[]): ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (block.type === 'image') { + next ??= blocks.slice(0, index) + next.push({ type: 'text', text: textOnlyImageText(block.attachment) }) + continue + } + if (block.type === 'tool-result') { + const content = replaceImagesForTextModel(block.content) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content }) + continue + } + } + next?.push(block) + } + return next ?? blocks as ContentBlock[] +} + +/** + * Project durable image history into deterministic text for an exact text-only model. + * @param messages - complete request history. + * @returns the original list without images, otherwise shallow message copies with stable placeholders. + */ +export function projectImagesForTextModel(messages: readonly Message[]): readonly Message[] { + if (!messages.some(message => contentHasImage(message.content))) return messages + return messages.map((message) => { + const content = replaceImagesForTextModel(message.content) + return content === message.content ? message : { ...message, content } + }) +} + /** * Return transient request messages whose oldest images are replaced until * their accumulated base64 payload fits the configured bound. The selection @@ -75,17 +153,47 @@ export function offloadRequestImages( messages: readonly Message[], maxRequestImageBytes: number | undefined, ): readonly Message[] { - if (maxRequestImageBytes === undefined) return messages + return offloadRequestImagesWithPolicy(messages, { + representation: 'base64', + ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, + byteQuantum: 1, + }) +} + +/** + * Return a deterministic transient projection whose oldest images are replaced + * in whole count and byte quanta after a route budget is exceeded. The target + * depends only on complete durable history: at 129 one-megabyte images under + * a 128 MiB bound with a 64 MiB quantum, the oldest 65 images are removed so + * 64 MiB remain; that removed prefix stays fixed until total history exceeds + * 192 MiB. + * @param messages - complete request history, oldest first. + * @param policy - route representation, budgets, and removal quanta. + * @returns original messages below both bounds, otherwise shallow copies with deterministic placeholders. + */ +export function offloadRequestImagesWithPolicy( + messages: readonly Message[], + policy: RequestImageOffloadPolicy, +): readonly Message[] { const lengths: number[] = [] - for (const message of messages) collectImageLengths(message.content, lengths) - let total = lengths.reduce((sum, bytes) => sum + bytes, 0) + for (const message of messages) collectImageLengths(message.content, lengths, policy) + const total = lengths.reduce((sum, bytes) => sum + bytes, 0) + const excessCount = policy.maxImages === undefined ? 0 : Math.max(0, lengths.length - policy.maxImages) + const excessBytes = policy.maxBytes === undefined ? 0 : Math.max(0, total - policy.maxBytes) + if (excessCount === 0 && excessBytes === 0) return messages + const countQuantum = policy.countQuantum ?? 1 + const byteQuantum = policy.byteQuantum ?? 1 + const removeCount = excessCount === 0 ? 0 : Math.ceil(excessCount / countQuantum) * countQuantum + const removeBytes = excessBytes === 0 ? 0 : Math.ceil(excessBytes / byteQuantum) * byteQuantum let count = 0 - for (const bytes of lengths) { - if (total <= maxRequestImageBytes) break - total -= bytes + let removedBytes = 0 + for (const imageBytes of lengths) { + const byteTargetMet = removeBytes === 0 + || (byteQuantum === 1 ? removedBytes >= removeBytes : removedBytes > removeBytes) + if (count >= removeCount && byteTargetMet) break + removedBytes += imageBytes count += 1 } - if (count === 0) return messages const remaining = { count } return messages.map((message) => { const content = replaceOldestImages(message.content, remaining) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index e87c428d06..82b64bf4ce 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -29,6 +29,7 @@ import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config. import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' import { normalizeApiKey } from './api-key.ts' +import { contentHasImage, projectImagesForTextModel } from './content.ts' export * from './attribution.ts' export * from './brand.ts' @@ -159,6 +160,8 @@ export interface PreparedLlmCall { readonly retryPolicy: ResolvedRetryPolicy /** Detached context metadata resolved with the registration-bound call. */ readonly context?: LlmModelContext + /** Exact model modalities captured with the adapter dispatch generation. */ + readonly inputModalities?: readonly ModelModality[] /** Config fields materialized by the captured adapter rather than proposed by the caller. */ readonly adapterDefaults: LlmCallConfigAdapterDefaults /** @@ -171,6 +174,14 @@ export interface PreparedLlmCall { stream(options: GenerateOptions): AsyncIterable } +/** One adapter-owned model-resolution generation bound to its eventual stream call. */ +export interface PreparedAdapterCall { + /** Exact model metadata from the same adapter generation as {@link stream}. */ + readonly model: LlmResolvedModelInfo + /** Dispatch through that generation without re-reading dynamic connection facts. */ + stream(options: GenerateOptions): AsyncIterable +} + /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include @@ -224,6 +235,22 @@ export abstract class LlmAdapter { return Promise.resolve({ provider, id: model, name: model }) } + /** + * Bind exact model metadata and the eventual request dispatch to one adapter generation. + * Dynamic adapters override this so settings changes between preparation and + * dispatch cannot combine one generation's capabilities with another's endpoint. + * @param provider - registered provider route. + * @param model - exact model id. + * @param signal - cancellation for model resolution. + * @returns model metadata and a one-generation stream entry point. + */ + async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise { + return { + model: await this.resolveModel(provider, model, signal), + stream: options => this.stream(options), + } + } + /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. @@ -629,8 +656,17 @@ export class LlmRuntime extends Service { model: string, signal?: AbortSignal, ): Promise { + const resolved = await registration.adapter.resolveModel(registration.provider.id, model, signal) + return this.normalizeModelInfo(registration, model, resolved) + } + + /** Validate and detach one adapter-returned exact model result. */ + private normalizeModelInfo( + registration: AdapterRegistration, + model: string, + resolved: LlmResolvedModelInfo, + ): LlmResolvedModelInfo { const provider = registration.provider.id - const resolved = await registration.adapter.resolveModel(provider, model, signal) if ( typeof resolved.provider !== 'string' || resolved.provider !== provider @@ -735,8 +771,16 @@ export class LlmRuntime extends Service { registration: AdapterRegistration, config: LlmCallConfig, signal?: AbortSignal, - ): Promise<{ config: LlmCallConfig; context?: LlmModelContext }> { + ): Promise<{ config: LlmCallConfig; context?: LlmModelContext; modelInfo: LlmResolvedModelInfo }> { const info = await this.resolveModelInfoFor(registration, config.model, signal) + return this.resolveCallWithInfo(config, info) + } + + /** Validate request controls against one already-bound exact model result. */ + private resolveCallWithInfo( + config: LlmCallConfig, + info: LlmResolvedModelInfo, + ): { config: LlmCallConfig; context?: LlmModelContext; modelInfo: LlmResolvedModelInfo } { const defaulted = config.maxTokens === undefined && info.defaultMaxTokens !== undefined ? { ...config, maxTokens: info.defaultMaxTokens } : config @@ -765,6 +809,7 @@ export class LlmRuntime extends Service { return { config: resolvedConfig, ...info.context === undefined ? {} : { context: info.context }, + modelInfo: info, } } @@ -778,7 +823,9 @@ export class LlmRuntime extends Service { */ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise { const registration = this.registration(config.provider) - const resolved = await this.resolveCallFor(registration, config, signal) + const adapterCall = await registration.adapter.prepareCall(config.provider, config.model, signal) + const modelInfo = this.normalizeModelInfo(registration, config.model, adapterCall.model) + const resolved = this.resolveCallWithInfo(config, modelInfo) const resolvedConfig = deepFreeze(structuredClone(resolved.config)) const context = resolved.context === undefined ? undefined @@ -797,6 +844,9 @@ export class LlmRuntime extends Service { retryPolicy: registration.retryPolicy, adapterDefaults, ...context === undefined ? {} : { context }, + ...modelInfo.inputModalities === undefined + ? {} + : { inputModalities: Object.freeze([...modelInfo.inputModalities]) }, stream: (options: GenerateOptions): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') @@ -808,7 +858,12 @@ export class LlmRuntime extends Service { ) } dispatched = true - return this.streamWithRegistration(options, { registration, config: resolvedConfig }) + return this.streamWithRegistration(options, { + registration, + config: resolvedConfig, + modelInfo, + dispatch: options => adapterCall.stream(options), + }) }, }) } @@ -842,14 +897,25 @@ export class LlmRuntime extends Service { */ private async * adapterStream( options: GenerateOptions, - prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, + prepared?: PreparedDispatch, ): AsyncGenerator { let iterator: AsyncIterator try { const registration = prepared?.registration ?? this.registration(options.provider) - const resolvedConfig = prepared === undefined - ? (await this.resolveCallFor(registration, options, options.signal)).config - : prepared.config + const adapter = registration.adapter + let modelInfo: LlmResolvedModelInfo + let resolvedConfig: LlmCallConfig + let dispatch: (options: GenerateOptions) => AsyncIterable + if (prepared === undefined) { + const adapterCall = await adapter.prepareCall(options.provider, options.model, options.signal) + modelInfo = this.normalizeModelInfo(registration, options.model, adapterCall.model) + resolvedConfig = this.resolveCallWithInfo(options, modelInfo).config + dispatch = options => adapterCall.stream(options) + } else { + modelInfo = prepared.modelInfo + resolvedConfig = prepared.config + dispatch = prepared.dispatch + } if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) { throw new LlmError( 'prepared LLM call config changed before adapter dispatch', @@ -861,8 +927,14 @@ export class LlmRuntime extends Service { : Object.isFrozen(options) ? deepFreeze({ ...options, ...resolvedConfig }) : { ...options, ...resolvedConfig } - const adapter = registration.adapter - const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter)) + const projectedOptions = modelInfo.inputModalities !== undefined + && !modelInfo.inputModalities.includes('image') + && resolvedOptions.messages.some(message => contentHasImage(message.content)) + ? Object.isFrozen(resolvedOptions) + ? deepFreeze({ ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] }) + : { ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] } + : resolvedOptions + const stream = dispatch(this.forAdapter(projectedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { yield adapterFailureChunk(error, options.signal) @@ -916,7 +988,7 @@ export class LlmRuntime extends Service { private streamWithRegistration( options: GenerateOptions, - prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, + prepared?: PreparedDispatch, ): AsyncIterable { return this.ctx.waterfall( this, @@ -944,4 +1016,11 @@ interface AdapterRegistration { readonly retryPolicy: ResolvedRetryPolicy } +interface PreparedDispatch { + readonly registration: AdapterRegistration + readonly config: LlmCallConfig + readonly modelInfo: LlmResolvedModelInfo + readonly dispatch: (options: GenerateOptions) => AsyncIterable +} + export default LlmRuntime diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index ffb5a586bf..d1b02fa011 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages } from '../src/index.ts' +import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages, offloadRequestImagesWithPolicy } from '../src/index.ts' import type { ContentBlock } from '../src/index.ts' const source = { kind: 'plugin' as const, plugin: 'test' } @@ -87,3 +87,33 @@ describe('offloadRequestImages', () => { ]) }) }) + +describe('offloadRequestImagesWithPolicy', () => { + it('drops 129 MiB to 64 MiB and keeps the removed prefix stable through 192 MiB', () => { + const mib = 1024 * 1024 + const project = (count: number) => offloadRequestImagesWithPolicy([ + createUserMessage({ content: Array.from({ length: count }, () => image(mib)), source }), + ], { + representation: 'raw', + maxBytes: 128 * mib, + byteQuantum: 64 * mib, + })[0]?.content + + expect(project(128)?.filter(block => block.type === 'image')).toHaveLength(128) + expect(project(129)?.filter(block => block.type === 'text')).toHaveLength(65) + expect(project(192)?.filter(block => block.type === 'text')).toHaveLength(65) + expect(project(193)?.filter(block => block.type === 'text')).toHaveLength(129) + }) + + it('rounds a count excess up to a 20-image removal step', () => { + const projected = offloadRequestImagesWithPolicy([ + createUserMessage({ content: Array.from({ length: 601 }, () => image(1)), source }), + ], { + representation: 'raw', + maxImages: 600, + countQuantum: 20, + }) + expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20) + expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581) + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 45f523c129..b2e5399964 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import LlmRuntime, { errorChain, GenerateOptions, @@ -13,6 +14,7 @@ import LlmRuntime, { resolveRetryPolicy, StreamChunk, createMessage, + createUserMessage, } from '@deepseek-ai/dsh-llm' import type { LlmModelContext, @@ -915,6 +917,77 @@ describe('LlmRuntime', () => { expect(resolutions).toBe(2) }) + it('binds adapter-owned capabilities and dispatch to one prepared generation', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + let generation = 'first' + let dispatched: string | undefined + const adapter = new class extends ScriptedAdapter { + override prepareCall(provider: string, model: string) { + const captured = generation + return Promise.resolve({ + model: { provider, id: model, name: model, inputModalities: ['text'] as const }, + stream: (options: GenerateOptions) => { + dispatched = captured + return super.stream(options) + }, + }) + } + }(SCRIPT) + ctx.llm.registerAdapter(['route'], adapter) + + const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) + generation = 'second' + expect(prepared.inputModalities).toEqual(['text']) + expect(Object.isFrozen(prepared.inputModalities)).toBe(true) + await collect(prepared.stream({ ...prepared.config, messages: [] })) + expect(dispatched).toBe('first') + }) + + it('projects historical images to stable text only after the loop-visible waterfall', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + const seen: GenerateOptions[] = [] + const adapter = new class extends ScriptedAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + seen.push(options) + yield * super.stream(options) + } + }(SCRIPT) + ctx.llm.registerAdapter(['route'], adapter) + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 3, + width: 1, + height: 1, + } + const waterfall: GenerateOptions[] = [] + ctx.on('llm/stream', async function* (options, next) { + waterfall.push(options) + yield * next() + }) + + await collect(ctx.llm.stream({ + provider: 'route', + model: 'text-only', + messages: [createUserMessage({ + content: [{ type: 'image', attachment }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(waterfall[0]?.messages[0]?.content).toEqual([{ type: 'image', attachment }]) + expect(seen[0]?.messages[0]?.content).toEqual([{ + type: 'text', + text: '[image omitted because this model accepts text only; attachment sha256:aaaaaaaa]', + }]) + }) + it('passes cancellation through exact-model resolution', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d190033b4f..854a86886c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5504,12 +5504,21 @@ importers: '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../identity/anonymous-user-id + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 255ff45001..558dafca6b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -293,6 +293,9 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', EncodedImageAttachment: 'attachment.md', ImageAttachmentRef: 'attachment.md', + ImageRequestPolicy: 'attachment.md', + PreviewImageCrop: 'attachment.md', + RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', SavedImageAttachment: 'attachment.md', SourceImageInfo: 'attachment.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 87eee0a7e4..48ba2255a5 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -314,18 +314,18 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-fs', dir: 'tool-fs', source: 'packages/fs/tool-fs/src/index.ts', - requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (read_image registration)', 'ctx.llm + an image-capable route (read_image execution)'], - writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image)', 'tool/result'], + requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (image-tool registration)', 'ctx.llm + an image-capable route (image-tool execution)'], + writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image and read_image_region)', 'tool/result'], async mount(ctx) { // The tool needs `fs`; the bare provider is sufficient because policy // changes behavior, not schema shape. The catalog seam marker opts into - // the attachments-conditional read_image schema without attachment I/O. + // both attachments-conditional image schemas without attachment I/O. await ctx.plugin(LocalFileSystem) await ctx.plugin(CatalogAttachmentStore) await ctx.plugin(ToolFs) }, note: - 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.', + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tools are not registered without `ctx.attachments`; their schemas are route-independent, and execution refuses unless the exact routed model declares image input.', }, { pkg: '@deepseek-ai/dsh-tool-fs-search', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8b580750bd..946015d483 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -930,6 +930,26 @@ "symbol": "StoredImageAttachment", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "MasterImageCrop", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ImageRequestPolicy", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "PreviewImageCrop", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "RequestImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/shell.md", "symbol": "ShellExecRequest", From c0dd8ec820cd5abd5be0345f750b957a1e926ac5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 18:25:37 +0800 Subject: [PATCH 045/248] chore(images): align merged runtime closure --- docs/subsystems/attachment.i18n.yaml | 4 ++-- docs/subsystems/attachment.md | 4 ++-- docs/subsystems/attachment.zh.md | 4 ++-- packages/extensions/tool-cordis/src/api-catalog.ts | 4 ++-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 1 + pnpm-lock.yaml | 3 +++ python/sdk-runtime/package.json | 1 + 7 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 7c236d6480..55a43dd247 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: cdbb528d30eabc74a9c3607d67e91af053c45e7e -attachment.zh.md: 79ee753d22c3adecaca153659181e113f2b3e728 +attachment.md: ec9d1f27bdde4a4d5b6e6e7328260bcb4af49948 +attachment.zh.md: e79c2df4ca168bcae4fd45e61812a4f86ce2194b diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index cdbb528d30..ec9d1f27bd 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -204,7 +204,7 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise /** * Generate or read an ordered batch of deterministic model-request versions. @@ -223,7 +223,7 @@ async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageReque * @param signal - optional cancellation. * @returns a new durable attachment reference suitable for a logged tool result. */ -async cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise +cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 79ee753d22..e79c2df4ca 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -204,7 +204,7 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise /** * Generate or read an ordered batch of deterministic model-request versions. @@ -223,7 +223,7 @@ async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageReque * @param signal - optional cancellation. * @returns a new durable attachment reference suitable for a logged tool result. */ -async cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise +cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ad5d04fd5d..1602c351b5 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -456,7 +456,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, { - signature: 'async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', + signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored master image.', parameters: [{ name: 'ref', description: 'durable provider-independent master reference.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', @@ -468,7 +468,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'request versions in the same order as `refs`.', }, { - signature: 'async cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise', + signature: 'cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise', description: 'Crop the stored master by coordinates measured on a model request preview and persist the result.', parameters: [{ name: 'ref', description: 'session-authorized master attachment.' }, { name: 'crop', description: 'preview dimensions and preview-coordinate rectangle.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'a new durable attachment reference suitable for a logged tool result.', diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 171f898db9..416802e246 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -89,6 +89,7 @@ describe('PiAiAdapter provider routing', () => { ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({ profiles: () => resolveProfiles(providers), resolveApiKey: () => Promise.resolve('test-key'), + auth: memoryAuth(), })) const prepared = await ctx.llm.prepareCall({ provider: 'deepseek', model: 'deepseek-v4-flash' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 854a86886c..ca87caa7d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8729,6 +8729,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../packages/util/atomic-write '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../packages/attachment/attachment diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index befad374c1..abf3e11a78 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -17,6 +17,7 @@ "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", From c09a42ccb51d136e214241164ebd2f91a9419ba9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 18:44:40 +0800 Subject: [PATCH 046/248] fix(images): parse listed missing Files ids --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 4 +- ...08-20-unified-image-request-pipeline.zh.md | 4 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 25 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 215 +++++++++++++++++- 8 files changed, 238 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 53c15e1755..4f1b156e3a 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: c487f583e4770b8d495404f08de67fd877dc48fd -2026-08-20-unified-image-request-pipeline.zh.md: a82312d55ba59403e71e97ee483e2b5bbfebfb03 +2026-08-20-unified-image-request-pipeline.md: 72382b6130086ba5c36d386ffe7ebe413cd2243d +2026-08-20-unified-image-request-pipeline.zh.md: 15560ad475af669cc4a2d9c46354a4da08528e0b diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index c487f583e4..72382b6130 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -36,7 +36,7 @@ Every retained request image is preceded by its complete attachment id, actual r The direct `deepseek-official` adapter uploads every retained request version through the OpenAI-compatible Files API and sends only `file_id` content blocks. There is no inline fallback. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by endpoint and API-key scope plus `variantId`. Uploads request seven days by default and record the returned `expires_at`; a mapping with no more than one hour remaining is replaced without a preceding retrieve call. The index never stores the API key. -An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports an expired, deleted, missing, or invalid id and names one used id, only that mapping is removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error deletes the configured number of oldest harness-owned `dsh-` files and retries once. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. +An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error deletes the configured number of oldest harness-owned `dsh-` files and retries once. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. ### Diagnostics @@ -64,7 +64,7 @@ Historical attachment objects that later disappear or fail integrity verificatio ## Verification -Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants, bound transform concurrency, preserve cache and upload identity, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from exact and ambiguous stale-id responses, delete quota files, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants, bound transform concurrency, preserve cache and upload identity, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, delete quota files, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index a82312d55b..15560ad475 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -36,7 +36,7 @@ Status: implemented 直接 `deepseek-official` 适配器通过 OpenAI 兼容 Files API 上传每张保留的请求版本,只发送 `file_id` 内容块,不提供内联回退。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期,并记录返回的 `expires_at`;本地映射剩余时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 -只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的某个 ID,适配器只删除该映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会删除配置数量的最旧 `dsh-` 文件,然后重试一次。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 +只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会删除配置数量的最旧 `dsh-` 文件,然后重试一次。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 ### 诊断 @@ -64,7 +64,7 @@ Status: implemented ## Verification -包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体 singleflight、变换并发上限、缓存与上传身份、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、精确和模糊失效响应只恢复一次、配额删除、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体 singleflight、变换并发上限、缓存与上传身份、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、配额删除、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 ## Consequences diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index c5aa0c7e8c..5d0e91eae1 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: da2044abe6f5201c1bed1ca6b529b34c34282ea8 -README.zh.md: d17d7a739640c31e9e88f154a11d5e24011e54f7 +README.md: ea82956d77f3157638aa078c56630994ccc61d75 +README.zh.md: ff8780f59a3caac7e08e5ab6e08c4b2b15d1b57d diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index da2044abe6..ea82956d77 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,7 +52,7 @@ An image-capable catalog entry declares `inputModalities: [text, image]` and may `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. -Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, crop, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports an expired, deleted, missing, or invalid file id and names a used id, the adapter removes only that mapping. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. +Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, crop, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. One quota upload failure triggers deletion of the configured number of oldest `dsh-` files and one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d17d7a7396..ff8780f59a 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 -上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算、裁剪区域及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的某个 ID,适配器只删除该映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算、裁剪区域及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 一次上传配额错误会触发删除配置数量的最旧 `dsh-` 文件,然后重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8d9381c67f..006882d745 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -171,13 +171,22 @@ function collectImageRefs( } } -function requestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { +/** + * Resolve the request-image budgets owned by one DeepSeek model route. + * @param model - Advertised model route and its optional image overrides. + * @returns Complete pixel and encoded-byte budgets. + * @internal + */ +export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { + let maxPixels: number + if (model.imagePixelBudget !== undefined) maxPixels = model.imagePixelBudget + else if (model.imageDetail === 'low') maxPixels = DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + else maxPixels = DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET return { - maxPixels: model.imagePixelBudget - ?? (model.imageDetail === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET), - maxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, + maxPixels, + maxBytes: model.imageMaxBytes === undefined + ? DEFAULT_REQUEST_IMAGE_MAX_BYTES + : model.imageMaxBytes, } } @@ -189,7 +198,7 @@ async function prepareRequestImages( ): Promise> { const refs = new Map() for (const message of options.messages) collectImageRefs(message.content, refs) - const policy = requestImagePolicy(model) + const policy = resolveRequestImagePolicy(model) const orderedRefs = [...refs.values()] const projected = await attachments.readImageRequests(orderedRefs, policy, signal) return new Map(orderedRefs.map((ref, index) => ( @@ -211,7 +220,7 @@ interface UsedRequestFile { function providerRejectedFileId(detail: string): boolean { const file = /\bfile(?:[_ -]?(?:id|api|not[_ -]?found|deleted|expired))?/iu.test(detail) - const missing = /(?:expired|not[_ -]?found|deleted|does not exist)/iu.test(detail) + const missing = /(?:expired|not[_ -]?found|deleted|do(?:es)? not exist|not created under (?:this|your) account)/iu.test(detail) const invalidId = /(?:invalid.{0,20}file[_ -]?(?:id|api)|file[_ -]?(?:id|api).{0,20}invalid)/iu.test(detail) return file && (missing || invalidId) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index e37a1a882e..23db2ed009 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import LlmRuntime, { createUserMessage, +import LlmRuntime, { CallId, createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, ProviderRequestId, QUOTA_EXCEEDED_CODE, @@ -18,7 +18,7 @@ import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/d import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' -import { httpErrorCode } from '../src/adapter.ts' +import { httpErrorCode, resolveRequestImagePolicy } from '../src/adapter.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' import type { Behavior } from './mock-server.ts' @@ -109,6 +109,25 @@ function attachmentStoreOf( } } +describe('request image policy', () => { + it.each([ + [ + { id: 'default' }, + { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + ], + [ + { id: 'low', imageDetail: 'low' as const }, + { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }, + ], + [ + { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, + { maxPixels: 320_000, maxBytes: 512_000 }, + ], + ])('resolves route-owned defaults and overrides for %s', (model, expected) => { + expect(resolveRequestImagePolicy(model)).toEqual(expected) + }) +}) + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -187,6 +206,54 @@ describe('DeepSeekAdapter against a mock server', () => { expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }]) }) + it('projects nested tool-result images with route-owned request budgets', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [ + { + id: 'vision-low', + inputModalities: ['text', 'image'], + imageDetail: 'low', + imageMaxBytes: 512_000, + }, + { + id: 'vision-custom', + inputModalities: ['text', 'image'], + imagePixelBudget: 320_000, + }, + ], + }, attachmentMocks.store) + const nested = createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('image-result'), + content: [{ type: 'image', attachment: imageRef }], + }], + source: { kind: 'plugin', plugin: 'test' }, + }) + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [nested] })) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [nested] })) + + expect(attachmentMocks.readImageRequests).toHaveBeenNthCalledWith( + 1, + [imageRef], + { maxPixels: 512 * 512, maxBytes: 512_000 }, + expect.any(AbortSignal), + ) + expect(attachmentMocks.readImageRequests).toHaveBeenNthCalledWith( + 2, + [imageRef], + { maxPixels: 320_000, maxBytes: 1024 * 1024 }, + expect.any(AbortSignal), + ) + }) + it('reuses the exact request version between agent and compaction calls', async () => { const server = await mockServer([ { kind: 'sse', events: textEvents }, @@ -219,7 +286,7 @@ describe('DeepSeekAdapter against a mock server', () => { }) it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { - const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } }) const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store const adapter = adapterOf({ @@ -248,11 +315,72 @@ describe('DeepSeekAdapter against a mock server', () => { cause: { message: raw }, }) expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') - expect((failure as Error).message).toContain('unsupported image payload') + expect((failure as Error).message).toContain('unsupported image payload for file-api-1') expect((failure as Error).message).not.toBe(raw) }) + it('identifies the sole image when a normalized rejection omits its file id', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ + message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string, + }) + }) + + it('lists every candidate when a normalized multi-image rejection names no file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf((ref) => { + const first = ref.attachmentId === imageRef.attachmentId + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), + master: first ? { ...ref, name: 'diagram.png' } : ref, + hasAlpha: false, + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining('Candidate images: "diagram.png"') as string, + cause: { message: raw }, + }) + }) + it.each([ + 'file-api-1 expired', + 'file_id file-api-10 invalid; file_id file-api-1 expired', 'file_id file-api-1 expired', 'file_not_found', 'file_id file-api-1 deleted', @@ -332,6 +460,71 @@ describe('DeepSeekAdapter against a mock server', () => { .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-3' }]) }) + it('invalidates every listed missing file id and preserves unlisted mappings', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const thirdRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: ' + + 'file-api-1, file-api-3, file-api-unknown', + }, + }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf((ref) => { + let digest = 'f' + if (ref.attachmentId === imageRef.attachmentId) digest = 'b' + else if (ref.attachmentId === secondRef.attachmentId) digest = 'd' + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${digest.repeat(64)}`), + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + { type: 'image', attachment: thirdRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) + .toEqual([ + { type: 'file', file_id: 'file-api-1' }, + { type: 'file', file_id: 'file-api-2' }, + { type: 'file', file_id: 'file-api-3' }, + ]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) + .toEqual([ + { type: 'file', file_id: 'file-api-4' }, + { type: 'file', file_id: 'file-api-2' }, + { type: 'file', file_id: 'file-api-5' }, + ]) + }) + it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { const secondRef: ImageAttachmentRef = { ...imageRef, @@ -644,6 +837,20 @@ describe('DeepSeekAdapter against a mock server', () => { }) }) + it('uses the HTTP status as the cause when an error response has no body', async () => { + const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }]) + const adapter = adapterOf({ baseURL: server.url }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [], + }))).rejects.toMatchObject({ + code: 'SERVER', + cause: { message: 'DeepSeek HTTP 500' }, + }) + }) + it('classifies an HTTP context-window failure with the canonical code', async () => { const server = await mockServer([{ kind: 'http-error', From de8ea5d715ba2dc402b02765832b3bda6453a78a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 18:52:42 +0800 Subject: [PATCH 047/248] docs: refresh image pipeline module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 5 ++++- docs/module-graph.zh.md | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 4dbba75888..4c6a9ea366 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: f17c65854dbf349adba5ff99288676a4f7eb7402 -module-graph.zh.md: 31608370c550ae7e32e1395e9f6833abf3d096c8 +module-graph.md: a7ba311ec4e7c744b970fdeec704c9c36bc81a20 +module-graph.zh.md: e3442640108559101e8ccd676a8ff2ca1fe2286c diff --git a/docs/module-graph.md b/docs/module-graph.md index f17c65854d..a7ba311ec4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -413,8 +413,11 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_home_paths pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment pkg_llm_deepseek --> pkg_llm @@ -1507,7 +1510,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 31608370c5..e344264010 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -415,8 +415,11 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_home_paths pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment pkg_llm_deepseek --> pkg_llm @@ -1509,7 +1512,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | From 48a58b90904babd586eb5b63dc58d5d2307400ef Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 20:01:43 +0800 Subject: [PATCH 048/248] fix(images): address unified pipeline review --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 10 +- ...08-20-unified-image-request-pipeline.zh.md | 10 +- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- apps/web/tests/shipped-composition.e2e.ts | 1 + docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- .../attachment-local/src/canonical.ts | 4 +- .../attachment/attachment-local/src/index.ts | 88 +++++++--- .../attachment-local/src/request-image.ts | 6 +- .../attachment-local/tests/canonical.spec.ts | 17 ++ .../tests/request-image.spec.ts | 27 +++ .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/fs/tool-fs/src/read-image.ts | 48 ++--- packages/host/apiproxy/src/api-proxy.ts | 1 - .../commands/tests/commands.spec.ts | 5 + packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 9 +- packages/llm/llm-deepseek/README.zh.md | 9 +- packages/llm/llm-deepseek/src/adapter.ts | 19 +- packages/llm/llm-deepseek/src/file-store.ts | 95 ++++++++-- packages/llm/llm-deepseek/src/files-api.ts | 5 +- packages/llm/llm-deepseek/src/index.ts | 6 + packages/llm/llm-deepseek/src/serialize.ts | 14 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 54 ++++++ .../llm/llm-deepseek/tests/file-store.spec.ts | 95 ++++++++++ .../llm/llm-deepseek/tests/files-api.spec.ts | 164 +++++++++++++++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 21 +++ .../llm-deepseek/tests/upload-index.spec.ts | 109 +++++++++++- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/config.ts | 6 +- packages/llm/llm-pi-ai/src/context.ts | 27 ++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 16 +- packages/llm/llm-pi-ai/tests/context.spec.ts | 60 ++++++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 32 +++- packages/llm/llm/src/content.ts | 11 +- 38 files changed, 849 insertions(+), 146 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 4f1b156e3a..07d9effb78 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 72382b6130086ba5c36d386ffe7ebe413cd2243d -2026-08-20-unified-image-request-pipeline.zh.md: 15560ad475af669cc4a2d9c46354a4da08528e0b +2026-08-20-unified-image-request-pipeline.md: 07632e9e0c3aac33d89acd8aebc0f0114550ddb6 +2026-08-20-unified-image-request-pipeline.zh.md: 9d95346dab2a7747c4bcef9f213ec0fa8e5ba067 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 72382b6130..07632e9e0c 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -24,19 +24,19 @@ Batch admission prepares and verifies every master once before publishing any me `AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 master projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. -The `variantId` and cache path cover the master attachment id, transform version, route pixel and byte budgets, optional master-coordinate crop, and fixed encoder parameters. Cached output is fully decoded before reuse. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the master byte count. Equal in-process `variantId` calls share one transform and cache write; cancellation rejects only that waiter. `AttachmentStore.readImageRequests` preserves input order while the local implementation runs master and request transforms through one FIFO limiter. `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every master has been prepared. +The `variantId` and cache path cover the master attachment id, transform version, route pixel and byte budgets, optional master-coordinate crop, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the master byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. `AttachmentStore.readImageRequests` preserves input order while the local implementation runs master and request transforms through one FIFO limiter. `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every master has been prepared. -Request-size offload is a deterministic oldest-first projection. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(masterBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained masters are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles and master-coordinate crops -Every retained request image is preceded by its complete attachment id, actual request dimensions, and the preview-coordinate arguments for `read_image_region`. The tool accepts only an attachment already referenced by the calling session. It maps the supplied preview rectangle to the 2048px master with floor-at-origin and ceil-at-far-edge rounding, crops the master rather than the preview, and persists the result as a new attachment. The tool result contains the new `ImageBlock`, so model-visible output and the durable log remain equivalent. +Every retained request image is preceded by its complete attachment id and actual request dimensions. When the active request exposes `read_image_region`, the text also supplies its preview-coordinate arguments. The tool accepts only an attachment already referenced by the calling session. It maps the supplied preview rectangle to the 2048px master with floor-at-origin and ceil-at-far-edge rounding, crops the master rather than the preview, and persists the result as a new attachment. The tool result contains the new `ImageBlock`, so model-visible output and the durable log remain equivalent. ### DeepSeek Files lifecycle The direct `deepseek-official` adapter uploads every retained request version through the OpenAI-compatible Files API and sends only `file_id` content blocks. There is no inline fallback. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by endpoint and API-key scope plus `variantId`. Uploads request seven days by default and record the returned `expires_at`; a mapping with no more than one hour remaining is replaced without a preceding retrieve call. The index never stores the API key. -An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error deletes the configured number of oldest harness-owned `dsh-` files and retries once. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. +An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. ### Diagnostics @@ -64,7 +64,7 @@ Historical attachment objects that later disappear or fail integrity verificatio ## Verification -Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants, bound transform concurrency, preserve cache and upload identity, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, delete quota files, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 15560ad475..9d95346dab 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -24,19 +24,19 @@ Status: implemented `AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 主版本在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 -`variantId` 和缓存路径覆盖主附件 ID、变换策略版本、路由像素和字节预算、可选的主版本坐标裁剪区域及固定编码参数。缓存输出会在复用前完整解码。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用主版本字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入;取消只拒绝对应等待方。`AttachmentStore.readImageRequests` 保持输入顺序,本地实现则通过一个 FIFO 限流器运行主版本和请求版本变换。`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部主版本准备完成后,批次仍按顺序发布。 +`variantId` 和缓存路径覆盖主附件 ID、变换策略版本、路由像素和字节预算、可选的主版本坐标裁剪区域及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用主版本字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。`AttachmentStore.readImageRequests` 保持输入顺序,本地实现则通过一个 FIFO 限流器运行主版本和请求版本变换。`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部主版本准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(主版本字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的主版本,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 ### 稳定句柄与主版本坐标裁剪 -每张保留请求图片前都有完整附件 ID、实际请求尺寸和 `read_image_region` 所需的预览坐标参数。该工具只接受调用会话已经引用的附件。它按起点向下取整、远端边界向上取整,把提交的预览矩形映射到 2048px 主版本,从主版本而非预览图裁剪,并把结果保存为新附件。工具结果包含新的 `ImageBlock`,因此模型可见输出与持久日志保持一致。 +每张保留请求图片前都有完整附件 ID 和实际请求尺寸。当前请求公开 `read_image_region` 时,这段文本还会提供预览坐标参数。该工具只接受调用会话已经引用的附件。它按起点向下取整、远端边界向上取整,把提交的预览矩形映射到 2048px 主版本,从主版本而非预览图裁剪,并把结果保存为新附件。工具结果包含新的 `ImageBlock`,因此模型可见输出与持久日志保持一致。 ### DeepSeek Files 生命周期 直接 `deepseek-official` 适配器通过 OpenAI 兼容 Files API 上传每张保留的请求版本,只发送 `file_id` 内容块,不提供内联回退。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期,并记录返回的 `expires_at`;本地映射剩余时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 -只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会删除配置数量的最旧 `dsh-` 文件,然后重试一次。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 +只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 ### 诊断 @@ -64,7 +64,7 @@ Status: implemented ## Verification -包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体 singleflight、变换并发上限、缓存与上传身份、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、配额删除、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 ## Consequences diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 0e98af0477..976381096a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -237,7 +237,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'read_image_region', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', ]) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 295e861b95..cca21dcef6 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -48,6 +48,7 @@ const EXPECTED_TOOLS = [ 'ralph', 'read', 'read_image', + 'read_image_region', 'send_message', 'skill', 'subagent', diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index ec9d1f27bd..66d00eb387 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -145,7 +145,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares a provider-independent 2048px, 4MiB master and atomically commits it before returning its reference. `saveImages()` prepares every validated master once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a master from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; `readImageRequests()` lets an implementation apply its configured bounded transform concurrency to an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, and defaults to two simultaneous transformations. `cropImage()` maps model preview coordinates back to the master and returns another durable attachment. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares a provider-independent 2048px, 4MiB master and atomically commits it before returning its reference. `saveImages()` prepares every validated master once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a master from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. `readImageRequests()` lets an implementation apply its configured transform concurrency to an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and defaults to two simultaneous transformations. `cropImage()` maps model preview coordinates back to the master and returns another durable attachment. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index e79c2df4ca..4c3a4ce427 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -145,7 +145,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备提供方无关的 2048px、4MiB 主版本,并在返回引用前以原子方式提交。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的主版本,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的主版本。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;`readImageRequests()` 允许实现按自身配置的有界变换并发处理有序批次。本地实现按需编码首选候选、合并相同请求身份的并发任务,默认同时执行两项变换。`cropImage()` 把模型预览坐标映射回主版本,并返回另一个持久附件。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备提供方无关的 2048px、4MiB 主版本,并在返回引用前以原子方式提交。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的主版本,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的主版本。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。`readImageRequests()` 允许实现按自身配置的变换并发处理有序批次。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,默认同时执行两项变换。`cropImage()` 把模型预览坐标映射回主版本,并返回另一个持久附件。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/canonical.ts index 8a4193aaed..c437448f4c 100644 --- a/packages/attachment/attachment-local/src/canonical.ts +++ b/packages/attachment/attachment-local/src/canonical.ts @@ -78,8 +78,8 @@ export async function hasLowColourCount(pipeline: Sharp): Promise { const colours = new Set() for (let offset = 0; offset < data.length; offset += info.channels) { const red = data[offset] ?? 0 - const green = data[offset + 1] ?? red - const blue = data[offset + 2] ?? red + const green = info.channels < 3 ? red : data[offset + 1] ?? red + const blue = info.channels < 3 ? red : data[offset + 2] ?? red const alpha = info.channels === 2 ? data[offset + 1] ?? 255 : info.channels === 4 ? data[offset + 3] ?? 255 : 255 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index e43153247a..46e39fb8ff 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -71,21 +71,59 @@ export interface Config { imageCompressionConcurrency?: number } -function waitForShared(operation: Promise, signal: AbortSignal | undefined): Promise { - if (signal === undefined) return operation - signal.throwIfAborted() - return new Promise((resolve, reject) => { - const abort = (): void => { - const reason: unknown = signal.reason - reject(reason instanceof Error - ? reason - : new Error('Attachment request cancelled with a non-Error reason.', { cause: reason })) - } - signal.addEventListener('abort', abort, { once: true }) - void operation.then(resolve, reject).finally(() => { - signal.removeEventListener('abort', abort) +function abortReason(signal: AbortSignal): Error { + const reason: unknown = signal.reason + return reason instanceof Error + ? reason + : new Error('Attachment request cancelled with a non-Error reason.', { cause: reason }) +} + +class SharedRequest { + readonly controller = new AbortController() + readonly promise: Promise + private settled = false + private waiters = 0 + + constructor(start: (signal: AbortSignal) => Promise) { + this.promise = start(this.controller.signal).finally(() => { + this.settled = true }) - }) + } + + wait(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + this.waiters += 1 + if (signal === undefined) return this.promise.finally(() => this.release(false)) + let released = false + const release = (cancelled: boolean): void => { + if (released) return + released = true + this.release(cancelled, signal) + } + return new Promise((resolve, reject) => { + const abort = (): void => { + release(true) + reject(abortReason(signal)) + } + signal.addEventListener('abort', abort, { once: true }) + void this.promise.then((value) => { + signal.removeEventListener('abort', abort) + release(false) + resolve(value) + }, (error: unknown) => { + signal.removeEventListener('abort', abort) + release(false) + reject(error) + }) + }) + } + + private release(cancelled: boolean, signal?: AbortSignal): void { + this.waiters -= 1 + if (cancelled && this.waiters === 0 && !this.settled && signal !== undefined) { + this.controller.abort(abortReason(signal)) + } + } } /** Persistent content-addressed local attachment store. */ @@ -111,7 +149,7 @@ export class LocalAttachmentStore extends AttachmentStore { /** Resolved instance-level compression limit. */ readonly imageCompressionConcurrency: number private readonly compression: CompressionLimiter - private readonly requestInflight = new Map>() + private readonly requestInflight = new Map>() constructor(ctx: Context, config: Config) { super(ctx) @@ -191,18 +229,24 @@ export class LocalAttachmentStore extends AttachmentStore { const variantId = requestImageVariantId(ref, policy) const key = String(variantId) let operation = this.requestInflight.get(key) + if (operation?.controller.signal.aborted) { + this.requestInflight.delete(key) + operation = undefined + } if (operation === undefined) { - operation = this.compression.run(async () => readRequestImageFile( + const shared = new SharedRequest(sharedSignal => this.compression.run(async () => readRequestImageFile( this.root, - master ?? await this.readImage(ref), + master ?? await this.readImage(ref, sharedSignal), policy, - )) - this.requestInflight.set(key, operation) - void operation.finally(() => { - if (this.requestInflight.get(key) === operation) this.requestInflight.delete(key) + sharedSignal, + ))) + operation = shared + this.requestInflight.set(key, shared) + void shared.promise.finally(() => { + if (this.requestInflight.get(key) === shared) this.requestInflight.delete(key) }).catch(() => {}) } - return waitForShared(operation, signal) + return operation.wait(signal) } override async cropImage( diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 9c92d78181..f65bc97ad5 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -19,7 +19,7 @@ import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' import { detectImage, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ -export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v2' +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v3' /** DeepSeek request versions normally fit at these two preferred qualities. */ export const REQUEST_IMAGE_QUALITIES = [85, 80] as const @@ -228,7 +228,7 @@ async function readCached( ): Promise { try { const data = new Uint8Array(await readFile(path, { signal })) - const detected = await detectImage(data) + const detected = await probeImage(data) const crop = policy.crop const maximum = requestImageDimensions(crop?.width ?? master.ref.width, crop?.height ?? master.ref.height, policy.maxPixels) if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' @@ -278,7 +278,7 @@ async function writeCached(path: string, data: Uint8Array): Promise { * @param root - absolute versioned attachment storage root. * @param master - verified stored master bytes and reference. * @param policy - exact route request-image policy. - * @param signal - optional cancellation for cache I/O. + * @param signal - optional cancellation for cache I/O and image transformation. * @returns verified request bytes and deterministic variant identity. */ export async function readRequestImageFile( diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/canonical.spec.ts index c1307b5848..a6f489615e 100644 --- a/packages/attachment/attachment-local/tests/canonical.spec.ts +++ b/packages/attachment/attachment-local/tests/canonical.spec.ts @@ -271,6 +271,23 @@ describe('hasLowColourCount', () => { await expect(hasLowColourCount(transparent)).resolves.toBe(true) }) + it('reads grayscale-alpha samples without treating alpha or the next pixel as RGB', async () => { + const symbols: number[] = [] + for (let first = 0; first < 32; first += 1) { + for (let second = 0; second < 32; second += 1) symbols.push(first, second) + } + const pixels = new Uint8Array(symbols.length * 2) + for (const [index, symbol] of symbols.entries()) { + pixels[index * 2] = symbol * 8 + pixels[index * 2 + 1] = symbol * 8 + } + const grayscaleAlpha = sharp(pixels, { + raw: { width: 128, height: 16, channels: 2 }, + }) + + await expect(hasLowColourCount(grayscaleAlpha)).resolves.toBe(true) + }) + it('keeps an antialiased text screenshot readable on the low-colour PNG path', async () => { const source = new Uint8Array(await sharp(Buffer.from(` diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 69bcfdf36c..3cdfadd32c 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -206,4 +206,31 @@ describe('local request-image cache', () => { expect(run).toHaveBeenCalledTimes(1) run.mockRestore() }) + + it('aborts the underlying request transform after its only waiter cancels', async () => { + const attachments = await store() + const master = (await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'cancelled.png', + })).ref + let readSignal: AbortSignal | undefined + const read = vi.spyOn(attachments, 'readImage').mockImplementation((_ref, signal) => { + readSignal = signal + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + const controller = new AbortController() + const request = attachments.readImageRequest( + master, + { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + controller.signal, + ) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(1)) + + const reason = new Error('cancel only transform waiter') + controller.abort(reason) + + await expect(request).rejects.toBe(reason) + expect(readSignal?.reason).toBe(reason) + }) }) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index ce2007c34b..50cbd49d57 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -31,7 +31,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_agents', 'lsp', 'pwsh', 'pwsh', 'ralph', - 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', + 'read', 'read_image', 'read_image_region', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', 'str_replace_editor', 'subagent', 'team_task_create', diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 4766bea6ba..a900c0c720 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -29,6 +29,22 @@ const IMAGE_EXTENSIONS: Readonly> = { '.gif': 'image/gif', } +const IMAGE_VALUE_SCHEMA = { + type: 'object', + additionalProperties: false, + required: true, + properties: { + attachmentId: { type: 'string', required: true }, + mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true }, + bytes: { type: 'integer', required: true }, + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + name: { type: 'string' }, + sourceWidth: { type: 'integer' }, + sourceHeight: { type: 'integer' }, + }, +} as const + /** The structured outcome declared by the `read_image` output schema. */ export interface ImageReadValue { path: string @@ -214,21 +230,7 @@ export function applyReadImageTool(ctx: Context): void { additionalProperties: false, properties: { path: { type: 'string', required: true }, - image: { - type: 'object', - additionalProperties: false, - required: true, - properties: { - attachmentId: { type: 'string', required: true }, - mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true }, - bytes: { type: 'integer', required: true }, - width: { type: 'integer', required: true }, - height: { type: 'integer', required: true }, - name: { type: 'string' }, - sourceWidth: { type: 'integer' }, - sourceHeight: { type: 'integer' }, - }, - }, + image: IMAGE_VALUE_SCHEMA, }, }, render: (_args, value) => imageReadContent(value), @@ -370,21 +372,7 @@ export function applyReadImageTool(ctx: Context): void { height: { type: 'integer', required: true }, }, }, - image: { - type: 'object', - additionalProperties: false, - required: true, - properties: { - attachmentId: { type: 'string', required: true }, - mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true }, - bytes: { type: 'integer', required: true }, - width: { type: 'integer', required: true }, - height: { type: 'integer', required: true }, - name: { type: 'string' }, - sourceWidth: { type: 'integer' }, - sourceHeight: { type: 'integer' }, - }, - }, + image: IMAGE_VALUE_SCHEMA, }, }, render: (_args, value) => regionReadContent(value), diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index dd1268fe00..ce29ba52b2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -185,7 +185,6 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b return undefined } -/** True when the current model-visible surface contains an image. */ /** Resolve the first reference matching one opaque id. */ function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { for (const event of events) { diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 5c80748227..c65fb49bed 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -486,6 +486,11 @@ describe('image attachments', () => { source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, }) }), + validateImageBatch(inputs: readonly unknown[]) { + return (AttachmentStore.prototype as unknown as { + validateImageBatch(this: unknown, batch: readonly unknown[]): void + }).validateImageBatch.call(this, inputs) + }, // The real base-class batch method over this double's limits and members. saveImages(inputs: readonly unknown[]) { return (AttachmentStore.prototype.saveImages as (this: unknown, batch: readonly unknown[]) => Promise).call(this, inputs) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 5d0e91eae1..71f7f71308 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: ea82956d77f3157638aa078c56630994ccc61d75 -README.zh.md: ff8780f59a3caac7e08e5ab6e08c4b2b15d1b57d +README.md: b20d93394055e3e10dfb5a932660b6a510428492 +README.zh.md: 6e8166227d740c0431c17c091d68b5d56aea0dc5 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index ea82956d77..b20d933940 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -23,6 +23,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire maxRequestFilesBytes: 134217728 # optional positive integer; 128 MiB raw request-image default maxImagesPerRequest: 600 # provider request image-count limit imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps + imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -48,13 +49,13 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 master becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id, actual request dimensions, and the preview-coordinate arguments for `read_image_region`. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 master becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. Preview-coordinate arguments are included only when the request exposes `read_image_region`. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. -`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. +`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained masters are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, crop, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. -One quota upload failure triggers deletion of the configured number of oldest `dsh-` files and one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. +Concurrent resolution of one scoped `variantId` shares one Files upload with waiter-local cancellation. One quota upload failure first paginates and collects the configured number of oldest `dsh-` files, then deletes that set before one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -80,7 +81,7 @@ The plugin also declares its route in the configurable-provider directory (`ctx. ## App attribution -Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compaction-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. +Every chat and Files API request carries the shared attribution header from dsh-llm's `attributionHeaders()`, the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compaction-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. DeepSeek request identity is separate from app attribution. After credential resolution, every provider request carries `x-deepseek-harness-user-id` with the stable anonymous id from [`@deepseek-ai/dsh-anonymous-user-id`](../../identity/anonymous-user-id/README.md); a request carrying `GenerateOptions.sessionId` also sends that exact value as `x-deepseek-harness-session-id`, while a direct call without a session omits the session header. Both headers go to the resolved `baseURL`, including a configured gateway, and remain outside the request body and model-visible content. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index ff8780f59a..6e8166227d 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -23,6 +23,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: maxRequestFilesBytes: 134217728 # optional positive integer; 128 MiB raw request-image default maxImagesPerRequest: 600 # provider request image-count limit imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps + imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -48,13 +49,13 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 主版本会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及 `read_image_region` 所需的预览坐标参数。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 主版本会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。只有当前请求公开 `read_image_region` 时才会提供预览坐标参数。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 -`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 +`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的主版本。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算、裁剪区域及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 -一次上传配额错误会触发删除配置数量的最旧 `dsh-` 文件,然后重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 +同一作用域和 `variantId` 的并发解析共享一次 Files 上传,每个等待方可以单独取消。一次上传配额错误会先分页收集配置数量的最旧 `dsh-` 文件,再删除这些文件并重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -80,7 +81,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 应用归因 -每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.zh.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compaction-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 +每个 chat 和 Files API 请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.zh.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compaction-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提供方请求都会通过 `x-deepseek-harness-user-id` 携带来自 [`@deepseek-ai/dsh-anonymous-user-id`](../../identity/anonymous-user-id/README.zh.md) 的稳定匿名 id;携带 `GenerateOptions.sessionId` 的请求还会通过 `x-deepseek-harness-session-id` 发送该确切值,缺少会话的直接调用则省略会话标头。两个标头都会发送至解析后的 `baseURL`(包括已配置的 gateway),且不会进入请求正文或模型可见内容。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 006882d745..3a01d424ca 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,7 +8,7 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, @@ -510,14 +510,24 @@ export class DeepSeekAdapter extends LlmAdapter { const fileConnection = { baseURL: connection.baseURL, apiKey } const model = connection.models.find(entry => entry.id === options.model) + const policy = model === undefined ? undefined : resolveRequestImagePolicy(model) + const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { + representation: 'raw', + maxBytes: connection.maxRequestFilesBytes, + maxImages: connection.maxImagesPerRequest, + byteQuantum: connection.imageOffloadByteQuantum, + countQuantum: connection.imageOffloadCountQuantum, + byteLength: ref => Math.min(ref.bytes, policy.maxBytes), + }) + const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } const requestImages = attachments === undefined || model === undefined ? new Map() - : await prepareRequestImages(options, attachments, model, signal) + : await prepareRequestImages(requestOptions, attachments, model, signal) for (let fileAttempt = 0; fileAttempt < 2; fileAttempt += 1) { const usedFiles: UsedRequestFile[] = [] const body = attachments === undefined - ? serializeRequest(options, connection.defaults) - : await serializeRequestWithImages(options, { + ? serializeRequest(requestOptions, connection.defaults) + : await serializeRequestWithImages(requestOptions, { requestImages, resolveFileId: async (version, _block, location) => { const resolved = await this.files.ensureUploaded( @@ -533,6 +543,7 @@ export class DeepSeekAdapter extends LlmAdapter { maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, + cropAvailable: options.tools?.some(tool => tool.name === 'read_image_region') ?? false, }, connection.defaults) const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts index 1ec943a7f9..ddaefd1eee 100644 --- a/packages/llm/llm-deepseek/src/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -36,6 +36,51 @@ interface FileStoreOptions { fetch?: typeof fetch } +interface SharedUpload { + controller: AbortController + promise: Promise + settled: boolean + waiters: number +} + +function abortReason(signal: AbortSignal): Error { + const reason: unknown = signal.reason + return reason instanceof Error + ? reason + : new Error('DeepSeek file upload cancelled with a non-Error reason.', { cause: reason }) +} + +function waitForUpload(operation: SharedUpload, signal: AbortSignal | undefined): Promise { + signal?.throwIfAborted() + operation.waiters += 1 + let released = false + const release = (cancelled: boolean): void => { + if (released) return + released = true + operation.waiters -= 1 + if (cancelled && operation.waiters === 0 && !operation.settled) { + operation.controller.abort(signal === undefined ? undefined : abortReason(signal)) + } + } + if (signal === undefined) return operation.promise.finally(() => release(false)) + return new Promise((resolve, reject) => { + const abort = (): void => { + release(true) + reject(abortReason(signal)) + } + signal.addEventListener('abort', abort, { once: true }) + void operation.promise.then((value) => { + signal.removeEventListener('abort', abort) + release(false) + resolve(value) + }, (error: unknown) => { + signal.removeEventListener('abort', abort) + release(false) + reject(error) + }) + }) +} + function extension(mediaType: RequestImageAttachment['mediaType']): 'png' | 'jpeg' | 'webp' | 'gif' { switch (mediaType) { case 'image/png': return 'png' @@ -56,7 +101,7 @@ export class DeepSeekFileStore { private readonly index: DeepSeekUploadIndex private readonly now: () => number private readonly fetchImpl: typeof fetch | undefined - private readonly inflight = new Map>() + private readonly inflight = new Map() /** * @param options - testable index, clock, and transport boundaries. @@ -76,11 +121,11 @@ export class DeepSeekFileStore { } /** - * Resolve or upload one deterministic request image. Concurrent calls in this process share one promise. + * Resolve or upload one deterministic request image. Concurrent calls share one upload while retaining independent waits. * @param version - deterministic model-request bytes and complete transformation identity. * @param connection - endpoint and API-key snapshot. * @param policy - expiry and quota-recovery policy. - * @param signal - request cancellation. + * @param signal - cancellation of this wait; shared transport stops when no waiter remains. * @returns a reusable file id and whether this call published a new upload. */ ensureUploaded( @@ -89,16 +134,34 @@ export class DeepSeekFileStore { policy: DeepSeekFilePolicy, signal?: AbortSignal, ): Promise { + signal?.throwIfAborted() const scope = deepSeekFileScope(connection.baseURL, connection.apiKey) const key = `${scope}\0${version.variantId}` - const active = this.inflight.get(key) - if (active !== undefined) return active - const operation = this.ensureUploadedOnce(version, connection, policy, signal) - this.inflight.set(key, operation) - void operation.finally(() => { - if (this.inflight.get(key) === operation) this.inflight.delete(key) + let active = this.inflight.get(key) + if (active?.controller.signal.aborted) { + this.inflight.delete(key) + active = undefined + } + if (active !== undefined) return waitForUpload(active, signal) + const controller = new AbortController() + const shared: SharedUpload = { + controller, + settled: false, + waiters: 0, + promise: undefined as never, + } + shared.promise = this.ensureUploadedOnce(version, connection, policy, controller.signal).then((value) => { + shared.settled = true + return value + }, (error: unknown) => { + shared.settled = true + throw error + }) + this.inflight.set(key, shared) + void shared.promise.finally(() => { + if (this.inflight.get(key) === shared) this.inflight.delete(key) }).catch(() => {}) - return operation + return waitForUpload(shared, signal) } private async ensureUploadedOnce( @@ -218,8 +281,8 @@ export class DeepSeekFileStore { ): Promise { const client = this.client(connection) let after: DeepSeekFileId | undefined - let deleted = 0 - while (deleted < count) { + const owned: DeepSeekFileId[] = [] + while (owned.length < count) { const page = await client.list({ ...after === undefined ? {} : { after }, limit: 1_000, @@ -228,14 +291,14 @@ export class DeepSeekFileStore { }) for (const file of page.data) { if (!file.filename.startsWith(OWNED_FILE_PREFIX)) continue - await client.delete(file.id, signal) - deleted += 1 - if (deleted === count) break + owned.push(file.id) + if (owned.length === count) break } if (!page.hasMore || page.lastId === undefined || page.lastId === after) break after = page.lastId } - return deleted + for (const fileId of owned) await client.delete(fileId, signal) + return owned.length } /** diff --git a/packages/llm/llm-deepseek/src/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts index 90ddf20b8c..f19823100e 100644 --- a/packages/llm/llm-deepseek/src/files-api.ts +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -1,6 +1,6 @@ /** OpenAI-compatible DeepSeek Files API transport. @module dsh-llm-deepseek/files-api */ -import { LlmError } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import { DeepSeekFileId } from './file-id.ts' import type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' @@ -142,7 +142,8 @@ export class DeepSeekFilesClient { private async request(path: string, init: RequestInit, signal?: AbortSignal): Promise { let response: Response try { - const headers = new Headers(init.headers) + const headers = new Headers(attributionHeaders()) + for (const [name, value] of new Headers(init.headers)) headers.set(name, value) headers.set('authorization', `Bearer ${this.apiKey}`) response = await this.fetchImpl(`${this.baseURL}${path}`, { ...init, diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6def44f2d3..919168439d 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -291,10 +291,16 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro if (!Number.isSafeInteger(imageOffloadByteQuantum) || imageOffloadByteQuantum <= 0) { throw new Error('llm-deepseek: imageOffloadByteQuantum must be a positive safe integer') } + if (imageOffloadByteQuantum > maxRequestFilesBytes) { + throw new Error('llm-deepseek: imageOffloadByteQuantum must not exceed maxRequestFilesBytes') + } const imageOffloadCountQuantum = config.imageOffloadCountQuantum ?? DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM if (!Number.isSafeInteger(imageOffloadCountQuantum) || imageOffloadCountQuantum <= 0) { throw new Error('llm-deepseek: imageOffloadCountQuantum must be a positive safe integer') } + if (imageOffloadCountQuantum > maxImagesPerRequest) { + throw new Error('llm-deepseek: imageOffloadCountQuantum must not exceed maxImagesPerRequest') + } const fileExpiresAfterSeconds = config.fileExpiresAfterSeconds ?? DEFAULT_FILE_EXPIRY_SECONDS if (!Number.isSafeInteger(fileExpiresAfterSeconds) || fileExpiresAfterSeconds < 3_600 diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f066808b99..a65c9750c3 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -37,7 +37,7 @@ export interface ImageSerializationOptions { block: Extract, location: ImageWireLocation, ) => Promise - /** Request versions prepared before offload selection, keyed by master attachment id. */ + /** Request versions prepared for the conservatively retained masters, keyed by attachment id. */ requestImages: ReadonlyMap /** Positive bound on accumulated referenced image bytes. */ maxRequestFilesBytes: number @@ -47,6 +47,8 @@ export interface ImageSerializationOptions { byteQuantum?: number /** Image-count removal step applied after the request exceeds its count bound. */ countQuantum?: number + /** Whether the active request exposes the region-read tool. */ + cropAvailable?: boolean } /** Durable message and image ordinal used in provider diagnostics. */ @@ -115,10 +117,14 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { } /** Describe the exact request preview and its model-callable coordinate system. */ -function imageHandle(version: RequestImageAttachment, precededByContent: boolean): WireTextContentPart { +function imageHandle( + version: RequestImageAttachment, + precededByContent: boolean, + cropAvailable: boolean, +): WireTextContentPart { return { type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImagePreviewText(version)}`, + text: `${precededByContent ? '\n' : ''}${requestImagePreviewText(version, cropAvailable)}`, } } @@ -137,7 +143,7 @@ async function imageParts( ) } return [ - imageHandle(version, precededByContent), + imageHandle(version, precededByContent, images.cropAvailable === true), { type: 'file', file_id: await images.resolveFileId(version, block, location) }, ] } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 23db2ed009..731df2eb0f 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -206,6 +206,49 @@ describe('DeepSeekAdapter against a mock server', () => { expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }]) }) + it('does not prepare an old image removed by request offload', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } + const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } + const attachmentMocks = attachmentStoreOf((ref) => { + if (ref.attachmentId === old.attachmentId) throw new Error('old image must not be read') + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + maxRequestFilesBytes: 4, + imageOffloadByteQuantum: 2, + }, attachmentMocks.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: old }, + { type: 'image', attachment: recent }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(attachmentMocks.readImageRequests).toHaveBeenCalledWith( + [recent], + { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + expect.any(AbortSignal), + ) + const body = server.requests[0] as { messages: unknown[] } + expect(body.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, + { type: 'file', file_id: 'file-api-1' }, + ], + }) + }) + it('projects nested tool-result images with route-owned request budgets', async () => { const server = await mockServer([ { kind: 'sse', events: textEvents }, @@ -1515,6 +1558,17 @@ describe('plugin registration and config', () => { }, ) + it('rejects offload quanta larger than their request bounds', () => { + expect(() => resolveAdapterOptions({ + maxRequestFilesBytes: 10, + imageOffloadByteQuantum: 11, + })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) + expect(() => resolveAdapterOptions({ + maxImagesPerRequest: 10, + imageOffloadCountQuantum: 11, + })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) + }) + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid request file bound %s', async (maxRequestFilesBytes) => { diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index 041fd0a0c6..6d9e940786 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -81,6 +81,63 @@ describe('DeepSeekFileStore', () => { expect(remote.uploads()).toBe(1) }) + it('keeps a shared upload alive while another waiter remains', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + let complete: ((response: Response) => void) | undefined + let uploadSignal: AbortSignal | undefined + const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { + uploadSignal = init?.signal ?? undefined + return new Promise((resolve, reject) => { + complete = resolve + uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { once: true }) + }) + }) as typeof fetch + const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl }) + const controller = new AbortController() + + const cancelled = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal) + const completed = store.ensureUploaded(VERSION, CONNECTION, POLICY) + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)) + const reason = new Error('cancel one upload waiter') + controller.abort(reason) + + await expect(cancelled).rejects.toBe(reason) + expect(uploadSignal?.aborted).toBe(false) + complete?.(new Response(JSON.stringify({ + id: 'file-api-shared', + object: 'file', + bytes: 3, + created_at: NOW / 1_000, + filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, + purpose: 'user_data', + expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + }), { status: 200 })) + await expect(completed).resolves.toMatchObject({ record: { fileId: 'file-api-shared' } }) + }) + + it('aborts the shared upload after its only waiter cancels', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + let uploadSignal: AbortSignal | undefined + const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { + uploadSignal = init?.signal ?? undefined + return new Promise((_resolve, reject) => { + uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { once: true }) + }) + }) as typeof fetch + const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl }) + const controller = new AbortController() + const upload = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal) + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)) + + const reason = new Error('cancel only upload waiter') + controller.abort(reason) + + await expect(upload).rejects.toBe(reason) + expect(uploadSignal?.reason).toBe(reason) + }) + it('does not persist an upload whose response is missing and retries on the next request', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) @@ -132,4 +189,42 @@ describe('DeepSeekFileStore', () => { await expect(store.release(VERSION, CONNECTION, POLICY)).resolves.toBe(false) expect(remote.fetchImpl).toHaveBeenCalledTimes(2) }) + + it('finishes pagination before deleting cursor files during quota recovery', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const deleted = new Set() + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const target = new URL(requestUrl(input)) + if (init?.method === 'DELETE') { + const id = target.pathname.split('/').at(-1) ?? '' + deleted.add(id) + return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }) + } + const after = target.searchParams.get('after') + if (after !== null && deleted.has(after)) throw new Error('deleted cursor cannot be reused') + const id = after === null ? 'file-api-oldest' : 'file-api-next' + return new Response(JSON.stringify({ + object: 'list', + data: [{ + id, + object: 'file', + bytes: 3, + created_at: NOW / 1_000, + filename: `dsh-${id}.png`, + purpose: 'user_data', + }], + first_id: id, + last_id: id, + has_more: after === null, + }), { status: 200 }) + }) as typeof fetch + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, 'index.json')), + now: () => NOW, + fetch: fetchImpl, + }) + + await expect(store.reclaimOldestOwned(CONNECTION, 2)).resolves.toBe(2) + expect([...deleted]).toEqual(['file-api-oldest', 'file-api-next']) + }) }) diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts index 752c659a7f..466c9be83b 100644 --- a/packages/llm/llm-deepseek/tests/files-api.spec.ts +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' +import { userAgent } from '@deepseek-ai/dsh-llm' import { DeepSeekFileId } from '../src/file-id.ts' -import { DeepSeekFilesClient, isFilesQuotaError } from '../src/files-api.ts' +import { + DeepSeekFilesClient, + DeepSeekFilesError, + isFilesQuotaError, + MAX_FILE_UPLOAD_BYTES, +} from '../src/files-api.ts' function requestUrl(input: string | URL | Request): string { if (typeof input === 'string') return input @@ -25,7 +31,9 @@ describe('DeepSeekFilesClient', () => { const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { expect(requestUrl(url)).toBe('https://api.deepseek.com/files') expect(init?.method).toBe('POST') - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer key') + const headers = new Headers(init?.headers) + expect(headers.get('authorization')).toBe('Bearer key') + expect(headers.get('user-agent')).toBe(userAgent()) const form = init?.body expect(form).toBeInstanceOf(FormData) if (!(form instanceof FormData)) throw new Error('expected multipart body') @@ -69,7 +77,7 @@ describe('DeepSeekFilesClient', () => { }) as typeof fetch const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) - await expect(client.list({ limit: 20, order: 'desc' })).resolves.toMatchObject({ + await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20, order: 'desc' })).resolves.toMatchObject({ data: [{ id: 'file-api-one' }], firstId: 'file-api-one', lastId: 'file-api-one', hasMore: false, }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one' }) @@ -98,5 +106,155 @@ describe('DeepSeekFilesClient', () => { data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, }).catch((caught: unknown) => caught) expect(isFilesQuotaError(error)).toBe(true) + expect(isFilesQuotaError(new Error('storage quota'))).toBe(false) + }) + + it.each([ + [401, 'AUTH'], + [403, 'AUTH'], + [429, 'RATE_LIMIT'], + [500, 'SERVER'], + [400, 'FILES_API'], + ] as const)('classifies HTTP %i Files failures as %s', async (status, code) => { + const client = new DeepSeekFilesClient({ + baseURL: 'https://api.deepseek.com', + apiKey: 'key', + fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))) as typeof fetch, + }) + await expect(client.retrieve(DeepSeekFileId('missing'))).rejects.toMatchObject({ + name: 'DeepSeekFilesError', + code, + detail: '', + }) + }) + + it.each([ + null, + [], + {}, + { error: null }, + { error: [] }, + { error: { message: 1, type: 2, code: 3 } }, + ])('falls back to the HTTP status for an unstructured provider error %#', async (body) => { + const client = new DeepSeekFilesClient({ + baseURL: 'https://api.deepseek.com', + apiKey: 'key', + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))) as typeof fetch, + }) + const error = await client.retrieve(DeepSeekFileId('missing')).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(DeepSeekFilesError) + expect(error).toMatchObject({ message: 'DeepSeek Files API error (HTTP 400)', detail: '' }) + }) + + it('wraps transport failures but preserves an aborted request reason', async () => { + const transport = new Error('socket closed') + const client = new DeepSeekFilesClient({ + baseURL: 'https://api.deepseek.com', + apiKey: 'key', + fetch: vi.fn(() => Promise.reject(transport)) as typeof fetch, + }) + await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({ + code: 'TRANSPORT', + cause: transport, + }) + + const controller = new AbortController() + const reason = new Error('cancelled') + controller.abort(reason) + await expect(client.retrieve(DeepSeekFileId('one'), controller.signal)).rejects.toBe(transport) + }) + + it.each([ + null, + [], + file({ id: 1 }), + file({ id: '' }), + file({ object: 'wrong' }), + file({ bytes: 1.5 }), + file({ bytes: -1 }), + file({ created_at: 1.5 }), + file({ created_at: -1 }), + file({ filename: 1 }), + file({ filename: '' }), + file({ purpose: 'assistants' }), + file({ expires_at: 1.5 }), + file({ expires_at: -1 }), + ])('rejects an invalid file object %#', async (body) => { + const client = new DeepSeekFilesClient({ + baseURL: 'https://api.deepseek.com', + apiKey: 'key', + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch, + }) + await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) + }) + + it.each([ + 3_599, + 2_592_001, + 3_600.5, + ])('refuses invalid file expiry %s before transport', async (expiresAfterSeconds) => { + const fetchImpl = vi.fn() as typeof fetch + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + await expect(client.upload({ + data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds, + })).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('refuses a file larger than the upload limit before transport', async () => { + const fetchImpl = vi.fn() as typeof fetch + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const data = { byteLength: MAX_FILE_UPLOAD_BYTES + 1 } as Uint8Array + await expect(client.upload({ + data, mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, + })).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it.each([ + null, + [], + {}, + { object: 'wrong', data: [], has_more: false }, + { object: 'list', data: null, has_more: false }, + { object: 'list', data: [], has_more: 0 }, + { object: 'list', data: [], has_more: false, first_id: 1 }, + { object: 'list', data: [], has_more: false, last_id: 1 }, + ])('rejects an invalid list response %#', async (body) => { + const client = new DeepSeekFilesClient({ + baseURL: 'https://api.deepseek.com', + apiKey: 'key', + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch, + }) + await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) + }) + + it('accepts a list without cursors and uses the global fetch default', async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + object: 'list', data: [], has_more: false, + }), { status: 200 }))) + vi.stubGlobal('fetch', fetchImpl) + try { + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com///', apiKey: 'key' }) + await expect(client.list()).resolves.toEqual({ data: [], hasMore: false }) + } finally { + vi.unstubAllGlobals() + } + }) + + it.each([ + null, + [], + {}, + { id: 'wrong', object: 'file', deleted: true }, + { id: 'file-api-one', object: 'wrong', deleted: true }, + { id: 'file-api-one', object: 'file', deleted: false }, + ])('rejects an invalid delete response %#', async (body) => { + const client = new DeepSeekFilesClient({ + baseURL: 'https://api.deepseek.com', + apiKey: 'key', + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch, + }) + await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 04717da6e0..8e04b9b3b0 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -60,6 +60,7 @@ function imageOptions( resolveFileId, requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), maxRequestFilesBytes, + cropAvailable: true, } } @@ -378,6 +379,26 @@ describe('image serialization', () => { }]) }) + it('does not advertise region reads when the request omits that tool', async () => { + const ref = imageRef() + const images = { ...imageOptions([ref]), cropAvailable: false } + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), images) + + expect(wire.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: `Image ${ref.attachmentId}; preview 1x1px.` }, + { type: 'file', file_id: 'file-api-image' }, + ], + }) + }) + it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { const messages = [ createUserMessage({ diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index 6157adc06e..480772f5fb 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -10,6 +10,11 @@ const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`) const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`) describe('DeepSeekUploadIndex', () => { + it('normalizes trailing endpoint slashes in the credential scope', () => { + expect(deepSeekFileScope('https://api.deepseek.com///', 'key')) + .toBe(deepSeekFileScope('https://api.deepseek.com', 'key')) + }) + it('isolates API-key namespaces and reuses only records above the refresh margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) @@ -70,4 +75,106 @@ describe('DeepSeekUploadIndex', () => { await expect(index.get(scope, VARIANT, 1, 1)).resolves.toEqual(record) expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ formatVersion: 2 }) }) + + it.each([ + 'null', + '[]', + '{}', + '{"formatVersion":1,"records":[]}', + '{"formatVersion":2,"records":null}', + '{"formatVersion":2,"records":[null]}', + '{"formatVersion":2,"records":[[]]}', + '{"formatVersion":2,"records":[{}]}', + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'x'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: 'wrong', variantId: VARIANT, + fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: 'wrong', + fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: '', bytes: 3, createdAt: 1, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: 'file-api-one', bytes: -1, createdAt: 1, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: 'file-api-one', bytes: 1.5, createdAt: 1, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: 'file-api-one', bytes: 3, createdAt: -1, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: 'file-api-one', bytes: 3, createdAt: 1.5, expiresAt: 10_000, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: -1, + })}]}`, + `{"formatVersion":2,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 1.5, + })}]}`, + ])('treats an invalid persisted index as empty %#', async (text) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + const path = join(dir, 'index.json') + await writeFile(path, text, 'utf8') + const index = new DeepSeekUploadIndex(path) + await expect(index.get( + deepSeekFileScope('https://api.deepseek.com', 'key'), VARIANT, 1, 1, + )).resolves.toBeUndefined() + }) + + it('rejects duplicate persisted mappings as a corrupt cache', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + const path = join(dir, 'index.json') + const scope = deepSeekFileScope('https://api.deepseek.com', 'key') + const record = { + scope, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: DeepSeekFileId('file-api-one'), bytes: 3, createdAt: 1, expiresAt: 10_000, + } + await writeFile(path, JSON.stringify({ formatVersion: 2, records: [record, record] }), 'utf8') + const index = new DeepSeekUploadIndex(path) + await expect(index.get(scope, VARIANT, 1, 1)).resolves.toBeUndefined() + }) + + it('drops expired records on commit and clears only the selected namespace', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + const first = deepSeekFileScope('https://api.deepseek.com', 'first') + const second = deepSeekFileScope('https://api.deepseek.com', 'second') + const expired = { + scope: first, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + fileId: DeepSeekFileId('file-api-expired'), bytes: 3, createdAt: 1, expiresAt: 2, + } + const live = { + ...expired, scope: second, fileId: DeepSeekFileId('file-api-live'), expiresAt: 10_000, + } + await index.commit(expired, 0, 0) + await index.commit(live, 3, 1) + await index.clear(first) + await index.clear(second) + await expect(index.get(second, VARIANT, 3, 1)).resolves.toBeUndefined() + await index.clear(second) + }) + + it('propagates non-cache filesystem read failures', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + const path = join(dir, 'directory') + await mkdir(path) + const index = new DeepSeekUploadIndex(path) + await expect(index.get( + deepSeekFileScope('https://api.deepseek.com', 'key'), VARIANT, 1, 1, + )).rejects.toBeInstanceOf(Error) + }) }) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 044038aa69..360da6a73b 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route first derives a deterministic request version from the provider-independent master under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual preview dimensions. `maxRequestImageBytes` then bounds the accumulated base64 length (default 20MiB): the oldest request versions are replaced by a fixed text placeholder until the request fits. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent master under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading masters, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual preview dimensions. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id, actual request dimensions, and `read_image_region` preview coordinates. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. The text includes `read_image_region` preview coordinates only when that tool is present in the request. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded masters are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index d4b5dff10e..bf05671ee3 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由先从提供方无关的主版本派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际预览尺寸。`maxRequestImageBytes` 再限制累计 base64 长度(默认 20MiB);超出时从最旧请求版本开始替换为固定文本占位,直到请求可容纳。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的主版本派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取主版本前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际预览尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸和 `read_image_region` 使用的预览坐标。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。只有请求包含 `read_image_region` 时,文本才会提供该工具使用的预览坐标。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的主版本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index c5af6bff6a..f06b27a1ea 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -360,7 +360,7 @@ export class PiAiAdapter extends LlmAdapter { } const context = attachments === undefined ? toPiContext(options, undefined, onReplayDegrade) - : await toPiContext(options, attachments, onReplayDegrade, profile.maxRequestImageBytes, { + : await toPiContext({ ...options, signal: watchdog.signal }, attachments, onReplayDegrade, profile.maxRequestImageBytes, { maxPixels: profile.requestImagePixelBudget, maxBytes: profile.requestImageMaxBytes, }) diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 2fd68d4648..5473d931de 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -46,9 +46,9 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 * Default request-level bound on base64-encoded image payload. Every image in * history is re-encoded into every request body, so an unbounded conversation * eventually exceeds a provider or gateway request-size cap and the session - * can never complete another request. The 20MiB default admits four images at - * the attachment store's 3.5MiB raw-image default after base64 expansion and - * reserves request capacity for system prompts, history, tools, and JSON. + * can never complete another request. The 20MiB default admits fifteen 1MiB + * request versions after base64 expansion and reserves request capacity for + * system prompts, history, tools, and JSON. * Deployments behind stricter gateways lower it per route. */ export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 5cf9b7c042..4c31d2638b 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -48,6 +48,7 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { async function userContent( blocks: readonly ContentBlock[], requestImages: ReadonlyMap, + cropAvailable: boolean, ): Promise { const content: (TextContent | ImageContent)[] = [] for (const block of blocks) { @@ -60,7 +61,7 @@ async function userContent( if (version === undefined) { throw new LlmError(`pi-ai request image ${block.attachment.attachmentId} was not prepared`, 'INVALID_REQUEST') } - content.push({ type: 'text', text: requestImagePreviewText(version) }) + content.push({ type: 'text', text: requestImagePreviewText(version, cropAvailable) }) content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), @@ -70,7 +71,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(block.content, requestImages) + const nested = await userContent(block.content, requestImages, cropAvailable) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -101,11 +102,16 @@ async function prepareRequestImages( messages: readonly Message[], attachments: AttachmentStore, policy: ImageRequestPolicy, + signal?: AbortSignal, ): Promise> { const refs = new Map() for (const message of messages) collectImageRefs(message.content, refs) + const orderedRefs = [...refs.values()] + const prepared = await attachments.readImageRequests(orderedRefs, policy, signal) const versions = new Map() - for (const [id, ref] of refs) versions.set(id, await attachments.readImageRequest(ref, policy)) + for (const [index, ref] of orderedRefs.entries()) { + versions.set(ref.attachmentId, prepared[index] as RequestImageAttachment) + } return versions } @@ -222,17 +228,24 @@ async function toPiContextWithImages( }, ): Promise { assertSupportedImageRoles(options.messages) - const requestImages = await prepareRequestImages(options.messages, attachments, requestImagePolicy) const requestMessages = offloadRequestImagesWithPolicy(options.messages, { + representation: 'base64', + ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, + byteQuantum: 1, + byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes), + }) + const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal) + const exactMessages = offloadRequestImagesWithPolicy(requestMessages, { representation: 'base64', ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => requestImages.get(ref.attachmentId)?.bytes ?? ref.bytes, }) + const cropAvailable = options.tools?.some(tool => tool.name === 'read_image_region') ?? false const toolNames = new Map() const messages: PiMessage[] = [] - for (const message of requestMessages) { + for (const message of exactMessages) { if (message.role === 'system') { // pi-ai has a single systemPrompt slot; in-history system messages are // folded into user messages to preserve order (rare in practice — the @@ -250,7 +263,7 @@ async function toPiContextWithImages( } // user role: text + tool results (each result becomes its own message). const regular = message.content.filter(block => block.type !== 'tool-result') - const content = await userContent(regular, requestImages) + const content = await userContent(regular, requestImages, cropAvailable) const results = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) @@ -258,7 +271,7 @@ async function toPiContextWithImages( messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { - const resultContent = await userContent(result.content, requestImages) + const resultContent = await userContent(result.content, requestImages, cropAvailable) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 416802e246..09befeaa4b 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -239,7 +239,11 @@ describe('PiAiAdapter provider routing', () => { } const readImage = vi.fn((_ref: ImageAttachmentRef): Promise => Promise.resolve({ ref, data: Uint8Array.of(1) })) - const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy): Promise => ( + const readImageRequest = vi.fn(( + value: ImageAttachmentRef, + _policy: ImageRequestPolicy, + _signal?: AbortSignal, + ): Promise => ( Promise.resolve({ variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), master: value, @@ -276,8 +280,12 @@ describe('PiAiAdapter provider routing', () => { return readImage(value) } - override readImageRequest(value: ImageAttachmentRef, policy: ImageRequestPolicy): Promise { - return readImageRequest(value, policy) + override readImageRequest( + value: ImageAttachmentRef, + policy: ImageRequestPolicy, + signal?: AbortSignal, + ): Promise { + return readImageRequest(value, policy, signal) } } @@ -301,7 +309,7 @@ describe('PiAiAdapter provider routing', () => { expect(readImageRequest).toHaveBeenCalledWith(ref, { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024, - }) + }, expect.any(AbortSignal)) expect(server.paths).toEqual(['/v1/responses']) }) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 7163a7375d..a0fb671c95 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentStore, + ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { toPiContext } from '../src/context.ts' @@ -30,11 +35,22 @@ function requestImage(value: ImageAttachmentRef, data: Uint8Array): RequestImage } function projectionStore( - readImageRequest = vi.fn((value: ImageAttachmentRef) => ( + readImageRequest: ( + value: ImageAttachmentRef, + policy: ImageRequestPolicy, + signal?: AbortSignal, + ) => Promise = vi.fn((value: ImageAttachmentRef) => ( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { readImageRequest } as unknown as AttachmentStore + return { + readImageRequest, + readImageRequests: ( + refs: readonly ImageAttachmentRef[], + policy: Parameters[1], + signal?: AbortSignal, + ) => Promise.all(refs.map(value => readImageRequest(value, policy, signal))), + } as unknown as AttachmentStore } const attachments = projectionStore() @@ -268,6 +284,42 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest).toHaveBeenCalledTimes(1) }) + it('does not prepare an old image removed by the conservative request projection', async () => { + const old = { ...ref, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } + const recent = { ...ref, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } + const readImageRequest = vi.fn((value: ImageAttachmentRef) => { + if (value.attachmentId === old.attachmentId) throw new Error('old image must not be read') + return Promise.resolve(requestImage(value, Uint8Array.of(1, 2, 3))) + }) + + const context = await toPiContext(request([user([ + { type: 'image', attachment: old }, + { type: 'image', attachment: recent }, + ])]), projectionStore(readImageRequest), undefined, 4) + + expect(context.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, + { type: 'image' }, + ], + }) + expect(readImageRequest).toHaveBeenCalledTimes(1) + expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) + }) + + it('advertises region reads only when the request exposes the tool', async () => { + const withoutCrop = await toPiContext(request([user([{ type: 'image', attachment: ref }])]), attachments) + const withCrop = await toPiContext({ + ...request([user([{ type: 'image', attachment: ref }])]), + tools: [{ name: 'read_image_region', description: 'crop', parameters: { type: 'object' } }], + }, attachments) + + expect(JSON.stringify(withoutCrop.messages)).not.toContain('Call read_image_region') + expect(JSON.stringify(withCrop.messages)).toContain('Call read_image_region') + }) + it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const exact = await toPiContext(request([ @@ -298,7 +350,7 @@ describe('pi-ai request context conversion', () => { expect(oversized.messages).toEqual([ { role: 'user', content: OFFLOADED_IMAGE_TEXT, timestamp: 0 }, ]) - expect(readImageRequest).toHaveBeenCalledTimes(1) + expect(readImageRequest).not.toHaveBeenCalled() }) it('offloads repeated image-block occurrences by position rather than shared object identity', async () => { diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index e77075b0c6..ccfb321d3b 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' @@ -58,6 +58,23 @@ function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { } } +function attachmentStore(readImageRequest: ( + ref: ImageAttachmentRef, + policy: ImageRequestPolicy, + signal?: AbortSignal, +) => Promise): AttachmentStore { + return { + readImageRequest, + readImageRequests: ( + refs: readonly ImageAttachmentRef[], + policy: ImageRequestPolicy, + signal?: AbortSignal, + ) => Promise.all( + refs.map(ref => readImageRequest(ref, policy, signal)), + ), + } as unknown as AttachmentStore +} + describe('toPiContext', () => { it('maps system prompt, user text, and tools', () => { const context = toPiContext({ @@ -91,7 +108,9 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve(requestVersion(value))) + const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => ( + Promise.resolve(requestVersion(value)) + )) const context = await toPiContext({ provider: 'openai', model: 'gpt-4.1', @@ -99,11 +118,12 @@ describe('toPiContext', () => { content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], source: { kind: 'plugin', plugin: 'test' }, })], - }, { readImageRequest } as unknown as AttachmentStore) + }, attachmentStore(readImageRequest)) expect(readImageRequest).toHaveBeenCalledWith( attachment, { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 }, + undefined, ) expect(context.messages[0]).toEqual({ role: 'user', @@ -124,7 +144,9 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve(requestVersion(value))) + const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => ( + Promise.resolve(requestVersion(value)) + )) const context = await toPiContext({ provider: 'openai', model: 'gpt-4.1', @@ -148,7 +170,7 @@ describe('toPiContext', () => { }], source: { kind: 'plugin', plugin: 'test' }, })], - }, { readImageRequest } as unknown as AttachmentStore) + }, attachmentStore(readImageRequest)) expect(context.messages).toEqual([{ role: 'toolResult', diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 96c39f4f9b..73a2aee889 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -21,12 +21,15 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { /** * Stable model-facing handle and coordinate description for one exact request preview. * @param version - exact request image shown beside the text. + * @param cropAvailable - whether the active request exposes `read_image_region`. * @returns attachment handle, preview dimensions, and crop-coordinate guidance. */ -export function requestImagePreviewText(version: RequestImageAttachment): string { - return `Image ${version.master.attachmentId}; preview ${version.width}x${version.height}px. ` - + 'Crop coordinates use this preview. Call read_image_region with this attachment_id, ' - + `preview_width=${version.width}, preview_height=${version.height}, x, y, width, and height.` +export function requestImagePreviewText(version: RequestImageAttachment, cropAvailable: boolean): string { + const identity = `Image ${version.master.attachmentId}; preview ${version.width}x${version.height}px.` + return cropAvailable + ? `${identity} Crop coordinates use this preview. Call read_image_region with this attachment_id, ` + + `preview_width=${version.width}, preview_height=${version.height}, x, y, width, and height.` + : identity } /** From 657ec56fbfc26cc03ae27e033f75f148796fd86c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 20:25:35 +0800 Subject: [PATCH 049/248] test(images): cover attachment projection edges --- .../attachment-local/src/canonical.ts | 14 +- .../attachment-local/src/encoding.ts | 11 +- .../attachment-local/src/request-image.ts | 6 +- .../attachment-local/tests/canonical.spec.ts | 43 +++++ .../attachment-local/tests/encoding.spec.ts | 27 ++- .../attachment-local/tests/index.spec.ts | 24 +++ .../tests/request-image-verification.spec.ts | 47 ++++++ .../tests/request-image.spec.ts | 158 +++++++++++++++++- .../attachment-local/tests/store.spec.ts | 12 +- .../attachment/attachment/tests/index.spec.ts | 34 ++++ packages/fs/tool-fs/tests/read-image.spec.ts | 128 ++++++++++++++ 11 files changed, 483 insertions(+), 21 deletions(-) create mode 100644 packages/attachment/attachment-local/tests/request-image-verification.spec.ts diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/canonical.ts index c437448f4c..513e5bbcc7 100644 --- a/packages/attachment/attachment-local/src/canonical.ts +++ b/packages/attachment/attachment-local/src/canonical.ts @@ -77,12 +77,10 @@ export async function hasLowColourCount(pipeline: Sharp): Promise { }).raw().toBuffer({ resolveWithObject: true }) const colours = new Set() for (let offset = 0; offset < data.length; offset += info.channels) { - const red = data[offset] ?? 0 - const green = info.channels < 3 ? red : data[offset + 1] ?? red - const blue = info.channels < 3 ? red : data[offset + 2] ?? red - const alpha = info.channels === 2 - ? data[offset + 1] ?? 255 - : info.channels === 4 ? data[offset + 3] ?? 255 : 255 + const red = data.readUInt8(offset) + const green = data.readUInt8(offset + 1) + const blue = data.readUInt8(offset + 2) + const alpha = info.channels === 4 ? data.readUInt8(offset + 3) : 255 colours.add(((red >> 3) << 15) | ((green >> 3) << 10) | ((blue >> 3) << 5) | (alpha >> 3)) if (colours.size > LOW_COLOUR_LIMIT) return false } @@ -183,8 +181,8 @@ export async function prepareMasterImage( const scale = Math.min(MIN_SCALE_STEP, sizeScale) const nextWidth = Math.max(1, Math.floor(width * scale)) const nextHeight = Math.max(1, Math.floor(height * scale)) - width = nextWidth === width && width > 1 ? width - 1 : nextWidth - height = nextHeight === height && height > 1 ? height - 1 : nextHeight + width = nextWidth + height = nextHeight } } catch (error) { if (error instanceof AttachmentError) throw error diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts index 8099046c95..963edda672 100644 --- a/packages/attachment/attachment-local/src/encoding.ts +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -20,16 +20,17 @@ export async function encodeFirstWithinLimit( attempts: readonly (() => Promise)[], maxBytes: number, ): Promise> { - if (attempts.length === 0) throw new Error('image encoding requires at least one candidate') - let smallest: T | undefined - for (const attempt of attempts) { + const [first, ...remaining] = attempts + if (first === undefined) throw new Error('image encoding requires at least one candidate') + let smallest = await first() + if (smallest.data.byteLength <= maxBytes) return smallest + for (const attempt of remaining) { const candidate = await attempt() if (candidate.data.byteLength <= maxBytes) return candidate - if (smallest === undefined || candidate.data.byteLength < smallest.data.byteLength) { + if (candidate.data.byteLength < smallest.data.byteLength) { smallest = candidate } } - if (smallest === undefined) throw new Error('image encoding did not execute a candidate') return { smallest } } diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index f65bc97ad5..ef7c841bed 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -263,11 +263,7 @@ async function writeCached(path: string, data: Uint8Array): Promise { const temporary = `${path}.${randomUUID()}.tmp` try { await writeFile(temporary, data, { mode: 0o600, flag: 'wx' }) - try { - await rename(temporary, path) - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'EEXIST') throw error - } + await rename(temporary, path) } finally { await rm(temporary, { force: true }) } diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/canonical.spec.ts index a6f489615e..8aa30511d6 100644 --- a/packages/attachment/attachment-local/tests/canonical.spec.ts +++ b/packages/attachment/attachment-local/tests/canonical.spec.ts @@ -230,6 +230,40 @@ describe('prepareMasterImage', () => { message: 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.', }) }) + + it.each([ + ['float PNG', { mediaType: 'image/png', depth: 'float' }], + ['uchar JPEG', { mediaType: 'image/jpeg', depth: 'uchar' }], + ] as const)('describes a failed %s conversion without exposing the encoder error', async (source, fields) => { + const detected = { + ...fields, + width: 5000, + height: 5000, + animated: false, + carriesMetadata: false, + space: 'srgb', + hasAlpha: false, + } as const + + await expect(prepareMasterImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: `The ${source} could not be converted to the canonical 8-bit sRGB form.`, + }) + }) + + it('rejects a converted master whose verified alpha metadata disagrees with the source facts', async () => { + const data = await flatImage(8, 8, 'png', true) + const detected = await detectImage(data) + + await expect(prepareMasterImage(data, { ...detected, hasAlpha: false }, { + maxDimension: 4, + maxBytes: POLICY.maxBytes, + })).rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'Canonical image conversion did not produce a single-frame 8-bit sRGB image with matching metadata.', + }) + }) }) describe('hasLowColourCount', () => { @@ -288,6 +322,15 @@ describe('hasLowColourCount', () => { await expect(hasLowColourCount(grayscaleAlpha)).resolves.toBe(true) }) + it('reads one-channel grayscale samples as equal RGB values', async () => { + const pixels = new Uint8Array(128 * 16) + for (let index = 0; index < pixels.length; index += 1) pixels[index] = index & 0xff + + await expect(hasLowColourCount(sharp(pixels, { + raw: { width: 128, height: 16, channels: 1 }, + }))).resolves.toBe(true) + }) + it('keeps an antialiased text screenshot readable on the low-colour PNG path', async () => { const source = new Uint8Array(await sharp(Buffer.from(` diff --git a/packages/attachment/attachment-local/tests/encoding.spec.ts b/packages/attachment/attachment-local/tests/encoding.spec.ts index c95d09c43c..d75fc9b4b3 100644 --- a/packages/attachment/attachment-local/tests/encoding.spec.ts +++ b/packages/attachment/attachment-local/tests/encoding.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { CompressionLimiter } from '../src/compression-limiter.ts' -import { encodeFirstWithinLimit } from '../src/encoding.ts' +import { encodeFirstWithinLimit, isExhaustedEncoding } from '../src/encoding.ts' describe('lazy image encoding', () => { it('does not execute fallback qualities after the first fitting candidate', async () => { @@ -22,6 +22,19 @@ describe('lazy image encoding', () => { expect(second).toHaveBeenCalledTimes(1) expect(third).not.toHaveBeenCalled() }) + + it('rejects an empty candidate list and reports the smallest exhausted candidate', async () => { + await expect(encodeFirstWithinLimit([], 8)).rejects.toThrow('requires at least one candidate') + const result = await encodeFirstWithinLimit([ + () => Promise.resolve({ data: new Uint8Array(12), quality: 85 }), + () => Promise.resolve({ data: new Uint8Array(9), quality: 80 }), + () => Promise.resolve({ data: new Uint8Array(10), quality: 75 }), + ], 8) + + expect(isExhaustedEncoding(result)).toBe(true) + expect(result).toMatchObject({ smallest: { quality: 80 } }) + expect(isExhaustedEncoding({ data: new Uint8Array(1) })).toBe(false) + }) }) describe('CompressionLimiter', () => { @@ -67,4 +80,16 @@ describe('CompressionLimiter', () => { await expect(failed).rejects.toThrow('synchronous setup failure') await expect(next).resolves.toBe('next') }) + + it('normalizes a non-Error rejection and releases its slot', async () => { + const limiter = new CompressionLimiter(1) + const failed = limiter.run(() => Promise.reject('native failure')) + const next = limiter.run(() => Promise.resolve('next')) + + await expect(failed).rejects.toMatchObject({ + message: 'Image compression task rejected with a non-Error value.', + cause: 'native failure', + }) + await expect(next).resolves.toBe('next') + }) }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 872aa5a3f7..89ada53298 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -58,6 +58,30 @@ describe('local attachment service', () => { } }) + it('commits a fully prepared image batch in input order', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-success-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const first = new Uint8Array(await sharp({ + create: { width: 2, height: 1, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().toBuffer()) + const second = new Uint8Array(await sharp({ + create: { width: 1, height: 2, channels: 3, background: { r: 4, g: 5, b: 6 } }, + }).png().toBuffer()) + + const refs = await service.saveImages([ + { data: first, mediaType: 'image/png', name: 'first.png' }, + { data: second, mediaType: 'image/png', name: 'second.png' }, + ]) + + expect(refs.map(ref => ref.name)).toEqual(['first.png', 'second.png']) + await expect(Promise.all(refs.map(ref => service.readImage(ref)))) + .resolves.toHaveLength(2) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) + it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit master object', async (channels) => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-16-bit-')) try { diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts new file mode 100644 index 0000000000..aae96e0b1d --- /dev/null +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -0,0 +1,47 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import sharp from 'sharp' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const control = vi.hoisted(() => ({ mismatch: false })) + +vi.mock('../src/image.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async detectImage(data: Uint8Array): Promise>> { + const detected = await actual.detectImage(data) + return control.mismatch ? { ...detected, width: detected.width + 1 } : detected + }, + } +}) + +import LocalAttachmentStore from '../src/index.ts' + +const homes: string[] = [] + +afterEach(async () => { + control.mismatch = false + await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) +}) + +describe('request image verification', () => { + it('rejects an encoded request whose decoded facts disagree with the encoder result', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-verification-')) + homes.push(dshHome) + const attachments = new LocalAttachmentStore(new Context(), { dshHome }) + const source = new Uint8Array(await sharp({ + create: { width: 64, height: 32, channels: 3, background: { r: 12, g: 34, b: 56 } }, + }).png().toBuffer()) + const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + control.mismatch = true + + await expect(attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', + }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 3cdfadd32c..e33522c104 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -39,9 +39,122 @@ describe('request image dimensions', () => { }) expect(projected.width * projected.height).toBeLessThanOrEqual(640_000) }) + + it('projects a portrait within the same total-pixel budget', () => { + const projected = requestImageDimensions(2160, 3840, 640_000) + + expect(projected).toEqual({ width: 600, height: 1066 }) + expect(projected.width * projected.height).toBeLessThanOrEqual(640_000) + }) + + it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => { + expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 }) + }) + + it('rejects invalid preview dimensions, origins, sizes, and bounds', () => { + expect(() => previewCropToMaster(0, 10, { + previewWidth: 10, previewHeight: 10, x: 0, y: 0, width: 1, height: 1, + })).toThrow('Master image width must be a positive integer') + expect(() => previewCropToMaster(10, 10, { + previewWidth: 0, previewHeight: 10, x: 0, y: 0, width: 1, height: 1, + })).toThrow('Preview width must be a positive integer') + expect(() => previewCropToMaster(10, 10, { + previewWidth: 10, previewHeight: 10, x: -1, y: 0, width: 1, height: 1, + })).toThrow('Preview crop origin must use non-negative integer pixels') + expect(() => previewCropToMaster(10, 10, { + previewWidth: 10, previewHeight: 10, x: 0, y: 0, width: 0, height: 1, + })).toThrow('Preview crop width must be a positive integer') + expect(() => previewCropToMaster(10, 10, { + previewWidth: 10, previewHeight: 10, x: 9, y: 0, width: 2, height: 1, + })).toThrow('Preview crop extends beyond the image shown to the model') + }) }) describe('local request-image cache', () => { + it('passes through an in-budget master and reads a request batch in input order', async () => { + const attachments = await store() + const first = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref + const second = (await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })).ref + const firstMaster = await attachments.readImage(first) + const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 } + + const request = await attachments.readImageRequest(first, policy) + const batch = await attachments.readImageRequests([first, second], policy) + + expect(request.data).toEqual(firstMaster.data) + expect(batch.map(value => value.master.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) + }) + + it('rejects invalid request policies and master crop bounds', async () => { + const attachments = await store() + const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref + + await expect(attachments.readImageRequest(master, { maxPixels: 0, maxBytes: 100 })) + .rejects.toThrow('Image request maxPixels must be a positive integer') + await expect(attachments.readImageRequest(master, { maxPixels: 100, maxBytes: 0 })) + .rejects.toThrow('Image request maxBytes must be a positive integer') + await expect(attachments.readImageRequest(master, { + maxPixels: 100, maxBytes: 100, crop: { x: -1, y: 0, width: 1, height: 1 }, + })).rejects.toThrow('Image crop origin must use non-negative integer pixels') + await expect(attachments.readImageRequest(master, { + maxPixels: 100, maxBytes: 100, crop: { x: 0, y: 0, width: 0, height: 1 }, + })).rejects.toThrow('Image crop width must be a positive integer') + await expect(attachments.readImageRequest(master, { + maxPixels: 100, maxBytes: 100, crop: { x: 7, y: 0, width: 2, height: 1 }, + })).rejects.toThrow('Image crop extends beyond the stored master image') + }) + + it('refuses a one-pixel request that cannot meet the encoded-byte budget', async () => { + const attachments = await store() + const master = (await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })).ref + + await expect(attachments.readImageRequest(master, { maxPixels: 1, maxBytes: 1 })) + .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + }) + + it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { + const attachments = await store() + const master = (await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })).ref + const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } + const initial = await attachments.readImageRequest(master, policy) + const hash = String(initial.variantId).slice('sha256:'.length) + const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash) + const noisyPixels = new Uint8Array(64 * 64 * 3) + let state = 0x2545f491 + for (let index = 0; index < noisyPixels.length; index += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + noisyPixels[index] = state & 0xff + } + const oversized = new Uint8Array(await sharp(noisyPixels, { + raw: { width: 64, height: 64, channels: 3 }, + }).png().toBuffer()) + const depth16 = new Uint8Array(await sharp({ + create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).toColourspace('rgb16').png().toBuffer()) + const cmyk = new Uint8Array(await sharp({ + create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).toColourspace('cmyk').jpeg().toBuffer()) + const tooWide = await image(23, 11) + const unexpectedAlpha = new Uint8Array(await sharp({ + create: { width: 16, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 0.5 } }, + }).png().toBuffer()) + + for (const invalid of [ + oversized, + depth16, + cmyk, + tooWide, + unexpectedAlpha, + Uint8Array.of(1, 2, 3), + ]) { + await writeFile(path, invalid) + const regenerated = await attachments.readImageRequest(master, policy) + expect(regenerated.data).toEqual(initial.data) + } + }) + it('derives stable square and wide previews and separates route budgets in the cache key', async () => { const attachments = await store() const square = (await attachments.saveImage({ @@ -99,6 +212,17 @@ describe('local request-image cache', () => { expect(pixel[1]).toBeGreaterThan(pixel[0] ?? 0) }) + it('names a crop from an unnamed attachment id', async () => { + const attachments = await store() + const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref + + const cropped = await attachments.cropImage(master, { + previewWidth: 8, previewHeight: 4, x: 0, y: 0, width: 4, height: 4, + }) + + expect(cropped.ref.name).toMatch(/^sha256:[0-9a-f]{8}-crop\.(?:png|webp|jpg)$/u) + }) + it('classifies opaque PNG pixels and preserves alpha while enforcing the request budget', async () => { const attachments = await store() const side = 256 @@ -233,4 +357,36 @@ describe('local request-image cache', () => { await expect(request).rejects.toBe(reason) expect(readSignal?.reason).toBe(reason) }) + + it('normalizes a non-Error cancellation and replaces an aborted shared transform', async () => { + const attachments = await store() + const master = (await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'replace.png', + })).ref + const actualRead = attachments.readImage.bind(attachments) + let calls = 0 + vi.spyOn(attachments, 'readImage').mockImplementation((ref, signal) => { + calls += 1 + if (calls === 1) { + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + } + return actualRead(ref, signal) + }) + const controller = new AbortController() + const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + const cancelled = attachments.readImageRequest(master, policy, controller.signal) + await vi.waitFor(() => expect(calls).toBe(1)) + + controller.abort('cancelled') + const replacement = attachments.readImageRequest(master, policy) + + await expect(cancelled).rejects.toMatchObject({ + message: 'Attachment request cancelled with a non-Error reason.', + cause: 'cancelled', + }) + await expect(replacement).resolves.toMatchObject({ width: 1130, height: 565 }) + expect(calls).toBe(2) + }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 97445c2f85..f0c127c174 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { MasterImagePolicy } from '../src/canonical.ts' -import { readImageFile, saveImageFile } from '../src/store.ts' +import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts' const fsControl = vi.hoisted(() => ({ readSignals: [] as AbortSignal[], @@ -256,4 +256,14 @@ describe('local attachment store', () => { await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) }) + + it('rejects prepared bytes that no longer match their content-addressed reference', async () => { + const storageRoot = await root() + const prepared = await prepareImageFile({ data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + + await expect(commitPreparedImageFile(storageRoot, { + ...prepared, + data: Uint8Array.of(...prepared.data, 0), + })).rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) }) diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 3a8fa23cbe..25afbcd420 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -76,6 +76,22 @@ class RecordingStore extends AttachmentStore { } } +class UnsupportedProjectionStore extends AttachmentStore { + readonly imageLimits = LIMITS + + validateImage(): Promise { + return Promise.resolve() + } + + saveImage(): Promise { + throw new Error('not used') + } + + readImage(): Promise { + throw new Error('not used') + } +} + function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment { return { data: Uint8Array.of(value), mediaType, name: `${value}.png` } } @@ -134,6 +150,24 @@ describe('AttachmentStore.readImageRequests', () => { expect(store.calls).toEqual(['request:1.png', 'request:2.png']) expect(versions.map(version => version.master.name)).toEqual(['1.png', '2.png']) }) + + it('reports unsupported request projection and crop operations, preserving cancellation', async () => { + const store = new UnsupportedProjectionStore(new Context()) + const ref = (await new RecordingStore(new Context()).saveImage(image(1))).ref + await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 })) + .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) + await expect(store.cropImage(ref, { + previewWidth: 1, previewHeight: 1, x: 0, y: 0, width: 1, height: 1, + })).rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) + + const controller = new AbortController() + const reason = new Error('cancel unsupported projection') + controller.abort(reason) + expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) + expect(() => store.cropImage(ref, { + previewWidth: 1, previewHeight: 1, x: 0, y: 0, width: 1, height: 1, + }, controller.signal)).toThrow(reason) + }) }) describe('isImageAdmissionError', () => { diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 2f67a464fd..3bf86d27f5 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -226,6 +226,134 @@ describe('read_image_region', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('not referenced by the current session') }) + + it('finds images nested in tool results after skipping a non-matching nested result', async () => { + const ctx = await setup() + const source = await ctx.attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png' }) + const history = [createUserMessage({ + content: [ + { type: 'tool-result', toolCallId: CallId('unrelated'), content: [{ type: 'text', text: 'none' }] }, + { type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'image', attachment: source.ref }] }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + const result = await call(ctx, 'read_image_region', { + attachment_id: source.ref.attachmentId, + preview_width: 3, + preview_height: 3, + x: 0, + y: 0, + width: 1, + height: 1, + }, agentOn('vision-model', 'visual', history)) + + expect(result.isError).toBe(false) + }) + + it('rejects a missing session, empty id, and invalid coordinate arguments', async () => { + const ctx = await setup() + const base = { + attachment_id: `sha256:${'f'.repeat(64)}`, + preview_width: 1, + preview_height: 1, + x: 0, + y: 0, + width: 1, + height: 1, + } + const noSession = await call(ctx, 'read_image_region', base) + expect(text(noSession)).toContain('requires an active agent session') + + const empty = await call(ctx, 'read_image_region', { ...base, attachment_id: ' ' }, agentOn('vision-model')) + expect(text(empty)).toContain('attachment_id must be a non-empty string') + + const source = await ctx.attachments.saveImage({ data: PNG_1X1, mediaType: 'image/png' }) + const history = [createUserMessage({ + content: [{ type: 'image', attachment: source.ref }], + source: { kind: 'plugin', plugin: 'test' }, + })] + const agent = agentOn('vision-model', 'visual', history) + for (const [field, value, expected] of [ + ['preview_width', 0, 'preview_width must be a positive integer'], + ['preview_height', 0, 'preview_height must be a positive integer'], + ['x', -1, 'x must be a non-negative integer'], + ['y', -1, 'y must be a non-negative integer'], + ['width', 0, 'width must be a positive integer'], + ['height', 0, 'height must be a positive integer'], + ] as const) { + const result = await call(ctx, 'read_image_region', { + ...base, + attachment_id: source.ref.attachmentId, + [field]: value, + }, agent) + expect(text(result)).toContain(expected) + } + }) + + it('projects optional crop metadata from a provider result', async () => { + class CropMetadataStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + maxImageDimension: 100, + mediaTypes: ['image/png'], + } + + validateImage(): Promise { return Promise.resolve() } + saveImage(): Promise { throw new Error('not used') } + readImage(): Promise { throw new Error('not used') } + override cropImage(ref: ImageAttachmentRef): Promise { + return Promise.resolve({ + ref: { ...ref, sourceWidth: 2, sourceHeight: 2 }, + source: { mediaType: ref.mediaType, bytes: ref.bytes, width: 2, height: 2 }, + }) + } + } + const ctx = await setup({ attachments: false }) + await ctx.plugin(CropMetadataStore) + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } + const history = [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })] + + const result = await call(ctx, 'read_image_region', { + attachment_id: ref.attachmentId, + preview_width: 1, + preview_height: 1, + x: 0, + y: 0, + width: 1, + height: 1, + }, agentOn('vision-model', 'visual', history)) + + expect(result.content[1]).toMatchObject({ + type: 'image', + attachment: { sourceWidth: 2, sourceHeight: 2 }, + }) + expect(result.content[1]).not.toHaveProperty('attachment.name') + }) + + it('declares a generic read presentation for image-region calls', async () => { + const ctx = await setup() + + expect(ctx.tools.get('read_image_region')?.presentCall?.({ + attachment_id: 'sha256:abc', + preview_width: 1, + preview_height: 1, + x: 0, + y: 0, + width: 1, + height: 1, + })) + .toEqual({ card: 'generic', title: 'Read image region sha256:abc', kind: 'read' }) + }) }) describe('read_image happy path', () => { From 72b204afa1753324430df36aab2c6ae29e952510 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 20:32:50 +0800 Subject: [PATCH 050/248] feat(images): expand source upload envelope --- ...26-07-05-reconstructable-requests.i18n.yaml | 2 +- .../2026-07-05-reconstructable-requests.zh.md | 2 +- ...20-unified-image-request-pipeline.i18n.yaml | 4 ++-- ...026-08-20-unified-image-request-pipeline.md | 2 +- ...-08-20-unified-image-request-pipeline.zh.md | 4 ++-- ...-08-20-attachment-read-quarantine.i18n.yaml | 2 +- ...2026-08-20-attachment-read-quarantine.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 12 ++++++------ docs/config-catalog.zh.md | 12 ++++++------ docs/subsystems/attachment.i18n.yaml | 4 ++-- docs/subsystems/attachment.md | 2 ++ docs/subsystems/attachment.zh.md | 2 ++ .../attachment-local/README.i18n.yaml | 4 ++-- packages/attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/index.ts | 18 +++++++++--------- .../attachment-local/tests/index.spec.ts | 6 +++++- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/http-bridge.ts | 6 +++--- packages/client/connection/src/index.ts | 2 +- .../connection/tests/node-half.host.spec.ts | 6 ++++++ 24 files changed, 61 insertions(+), 47 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 47c4c2d198..23c4ea4142 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md 2026-07-05-reconstructable-requests.md: 3f49ba71a6b98a84b05530c900e902b0cf9f6449 -2026-07-05-reconstructable-requests.zh.md: 8eee44449140d656a669ac506057e4fa09c2f747 +2026-07-05-reconstructable-requests.zh.md: 7b8a9df65b60f975bc3ae60b2c1b0c3a8cc22e95 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 8eee444491..7b8a9df65b 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -51,6 +51,6 @@ Status: implemented - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。 - 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。 -- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md)记录了不削弱字节精确重建的拟议恢复方案。 +- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 07d9effb78..a721138585 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 07632e9e0c3aac33d89acd8aebc0f0114550ddb6 -2026-08-20-unified-image-request-pipeline.zh.md: 9d95346dab2a7747c4bcef9f213ec0fa8e5ba067 +2026-08-20-unified-image-request-pipeline.md: c4af375d94ebf2b52fbdd0e8d3d4ee715f87f50e +2026-08-20-unified-image-request-pipeline.zh.md: a1e10c63804b42da127bd115c35587191f0a60f0 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 07632e9e0c..c4af375d94 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -14,7 +14,7 @@ The image path has two explicit versions. The attachment backend owns a provider ### Provider-independent master -Admission fully decodes each source under a configurable 32MiB, 100MP, and 16384px-per-side envelope. It applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `masterMaxDimension`, 2048px by default. `sourceWidth` and `sourceHeight` record orientation-applied dimensions when preparation reduces the raster. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Preparation applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `masterMaxDimension`, 2048px by default. `sourceWidth` and `sourceHeight` record orientation-applied dimensions when preparation reduces the raster. The master has an independent `masterMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both master limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 9d95346dab..a1e10c6380 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 提供方无关的主版本 -准入在可配置的 32MiB、1 亿像素和单边 16384px 源图范围内完整解码每张图片。处理会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`,默认 2048px。处理缩小光栅时,`sourceWidth` 和 `sourceHeight` 记录应用方向后的源尺寸。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。处理会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`,默认 2048px。处理缩小光栅时,`sourceWidth` 和 `sourceHeight` 记录应用方向后的源尺寸。 主版本有独立的 `masterMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 @@ -42,7 +42,7 @@ Status: implemented 16-bit RGB 或 RGBA PNG 属于普通可接纳输入,会转换为 8-bit sRGB/sRGBA。本地转换失败时,`read_image` 会写明路径、检测到的 16-bit PNG、所需规范形式和手工转换方法。如果 DeepSeek 拒绝已规范化请求版本,主错误会写明附件 ID 或显示名称、持久消息和图片位置、规范化媒体类型、8-bit sRGB/sRGBA 位深、尺寸和提供方消息。多图片错误无法确定对象时会列出全部候选图片。原始提供方正文保留为错误 cause,不会成为唯一可见消息。 -持久附件对象之后缺失或无法通过完整性校验时,系统仍会明确失败。持久隔离和经校验恢复需要新增会话事件,由[隔离不可读历史附件](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md)继续跟踪。 +持久附件对象之后缺失或无法通过完整性校验时,系统仍会明确失败。持久隔离和经校验恢复需要新增会话事件,由[隔离不可读历史附件](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)继续跟踪。 ## Alternatives considered diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml index ce37d7b8d3..b53d43cdc7 100644 --- a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md 2026-08-20-attachment-read-quarantine.md: 28e0f26cee2ec1e257fd4d43b4edc4300e2c6f23 -2026-08-20-attachment-read-quarantine.zh.md: bdc1d580a5159edcd288552e1bde9d80ea1eafd8 +2026-08-20-attachment-read-quarantine.zh.md: 7f4ceae4e1fe9e656ed762de9828e14145976dc3 diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md index bdc1d580a5..7f4ceae4e1 100644 --- a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -已接纳的 `ImageAttachmentRef` 会留在持久历史中,因此在被压缩替换前都会参与之后的每次请求。引用对象丢失、完整性校验失败或无法读取时,`AttachmentStore.readImage()` 会返回 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。未变化的历史随后会让之后每次模型请求在同一对象上失败,使会话无法继续,即使其余消息仍可使用。这是[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)保留为明确失败的对象不可用情况。 +已接纳的 `ImageAttachmentRef` 会留在持久历史中,因此在被压缩替换前都会参与之后的每次请求。引用对象丢失、完整性校验失败或无法读取时,`AttachmentStore.readImage()` 会返回 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。未变化的历史随后会让之后每次模型请求在同一对象上失败,使会话无法继续,即使其余消息仍可使用。这是[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md)保留为明确失败的对象不可用情况。 ## 提案 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3523b633cd..804d5dd86a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: dd91a870ecb338e784acdd1ffa0a470fa33d8813 -config-catalog.zh.md: a412a4f0afe652863cda1edad0e344b17e1697ac +config-catalog.md: 661e9a50200fd5c650c389d9bb631c04de61d228 +config-catalog.zh.md: 4299bccc1f59899bd78fd64f915c784e26eea49d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dd91a870ec..661e9a5020 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -327,15 +327,15 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one submitted image. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ maxImageBytes?: number - /** Maximum image count accepted in one submitted message. */ + /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number - /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + /** Maximum aggregate encoded image bytes accepted in one submitted message. Default: 200 MiB. */ maxMessageImageBytes?: number - /** Maximum intrinsic width multiplied by height accepted for one submitted image. */ + /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent master version. */ masterMaxDimension?: number @@ -413,7 +413,7 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] - /** Maximum buffered JSON body for every `/api` request. */ + /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ maxRequestBodyBytes?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a412a4f0af..4299bccc1f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -329,15 +329,15 @@ export interface Config { export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one submitted image. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ maxImageBytes?: number - /** Maximum image count accepted in one submitted message. */ + /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number - /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + /** Maximum aggregate encoded image bytes accepted in one submitted message. Default: 200 MiB. */ maxMessageImageBytes?: number - /** Maximum intrinsic width multiplied by height accepted for one submitted image. */ + /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent master version. */ masterMaxDimension?: number @@ -415,7 +415,7 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] - /** Maximum buffered JSON body for every `/api` request. */ + /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ maxRequestBodyBytes?: number } ``` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 55a43dd247..e391a3aa27 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: ec9d1f27bdde4a4d5b6e6e7328260bcb4af49948 -attachment.zh.md: e79c2df4ca168bcae4fd45e61812a4f86ce2194b +attachment.md: ea15172e3e1fafec2e09c3bedc2590fc7551eb2e +attachment.zh.md: c04114c9691fa1ba03446f903c4baf5ae021da4c diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 66d00eb387..99d4ba7682 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -52,6 +52,8 @@ interface ImageAttachmentLimits { } ``` +The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 20 MiB, 64,000,000 pixels, and 8192 pixels on either side. These source limits precede the independent 2048-pixel, 4 MiB master preparation stage. + The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. ## Commit and verified-read payloads diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 4c3a4ce427..d235c9ed2e 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -52,6 +52,8 @@ interface ImageAttachmentLimits { } ``` +本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 20 MiB、64,000,000 像素和单边 8192 像素。这些源文件限制先于独立的 2048 像素、4 MiB 主版本处理阶段执行。 + 引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。 ## 提交与经校验读取的数据 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 0ebf6a80fe..d15a1fd01e 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: 77b68357d5a961549bef0a015b8e48ba02fbd702 -README.zh.md: 05932c93e40d42a7f8fcdcf906f6669f6f8f7073 +README.md: 6141b7559492aa4c50831c8124a917bfdb704f4b +README.zh.md: 2a8ed6e1aef8022aba5053bf1ef0f9728340d086 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 77b68357d5..6141b75594 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission fully decodes the raster against a wide source envelope: 32MiB, 100MP, and 16384px per side by default. It then prepares a provider-independent master. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `masterMaxDimension` (2048px by default). The master has its own `masterMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both master limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and a converted master are each fully decoded once. `saveImages` prepares and verifies every master once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent master. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `masterMaxDimension` (2048px by default). The master has its own `masterMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both master limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and a converted master are each fully decoded once. `saveImages` prepares and verifies every master once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored master under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It also executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the master id, transform version, pixel and byte budgets, optional master-coordinate crop, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. `readImageRequests` schedules batches through the service's FIFO limiter. `imageCompressionConcurrency` controls simultaneous master and request transforms from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `cropImage` maps coordinates measured on a model preview back to the master, crops the master rather than the preview, and commits the crop as another durable attachment. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 05932c93e4..2a8ed6e1ae 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,7 +4,7 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -准入针对宽松的源图范围完整解码光栅,默认上限为 32MiB、1 亿像素和单边 16384px。随后生成提供方无关的主版本:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`(默认 2048px)。主版本有独立的 `masterMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的主版本各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次主版本,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的主版本:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`(默认 2048px)。主版本有独立的 `masterMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的主版本各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次主版本,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的主版本缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选仍按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含主版本 ID、变换策略版本、像素和字节预算、可选的主版本坐标裁剪区域以及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。`readImageRequests` 通过服务的 FIFO 限流器调度批次。`imageCompressionConcurrency` 控制同时执行的主版本和请求版本变换,范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。`cropImage` 把模型在预览图上测得的坐标映射回主版本,从主版本而非预览图裁剪,并把裁剪结果提交为另一个持久附件。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 46e39fb8ff..516280548c 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -27,15 +27,15 @@ export type { PreparedImageFile } from './store.ts' export { previewCropToMaster, readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts' /** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ -export const DEFAULT_MAX_IMAGE_BYTES = 32 * 1024 * 1024 +export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024 /** Default maximum images in one prompt. */ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ -export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024 +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 200 * 1024 * 1024 /** Default maximum intrinsic pixels for one submitted image. */ -export const DEFAULT_MAX_IMAGE_PIXELS = 100_000_000 +export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 /** Default per-side pixel cap for one submitted image. */ -export const DEFAULT_MAX_IMAGE_DIMENSION = 16384 +export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 /** * Default long-edge target of the stored image master. A larger source * is admitted and downscaled to this edge, so admission bounds what rides @@ -53,15 +53,15 @@ export const MAX_IMAGE_COMPRESSION_CONCURRENCY = 8 export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one submitted image. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ maxImageBytes?: number - /** Maximum image count accepted in one submitted message. */ + /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number - /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + /** Maximum aggregate encoded image bytes accepted in one submitted message. Default: 200 MiB. */ maxMessageImageBytes?: number - /** Maximum intrinsic width multiplied by height accepted for one submitted image. */ + /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent master version. */ masterMaxDimension?: number diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 89ada53298..c3c7693614 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -19,7 +19,11 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) - expect(DEFAULT_MAX_IMAGE_BYTES).toBe(32 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(20 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGES_PER_MESSAGE).toBe(20) + expect(DEFAULT_MAX_MESSAGE_IMAGE_BYTES).toBe(200 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_PIXELS).toBe(64_000_000) + expect(DEFAULT_MAX_IMAGE_DIMENSION).toBe(8192) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 44a428fa61..9d430a14a2 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.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 packages/client/connection/README.md -README.md: a7562b9dac57930b1abc0b76b9079a6865a38b35 -README.zh.md: 24c56e598ebd4b5ca39e433c5782399909f528b8 +README.md: 71ef204a589bb67c15ccab58d3cac5a13782ce27 +README.zh.md: 6d33ac3c13cdfceeba6e7472b618084267d09bbc diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index a7562b9dac..71ef204a58 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -23,4 +23,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path. -- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 160 MiB, sized for the default 100 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. +- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 24c56e598e..6d33ac3c13 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -23,4 +23,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r ## 已知限制与暂缓事项 - **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。 -- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 160 MiB,按默认 100 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 +- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index c26d83b6b7..07fc0fc5da 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -6,10 +6,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' /** Default carrier cap for all HTTP RPC bodies: sized for the default - * aggregate image limit (100 MiB) after base64 expansion plus envelope - * headroom (~134.3 MiB required), rounded up for slack. The bridge buffers + * aggregate image limit (200 MiB) after base64 expansion plus envelope + * headroom (~267.7 MiB required), rounded up for slack. The bridge buffers * each body in memory, so this cap is also the per-request resident bound. */ -export const DEFAULT_MAX_REQUEST_BODY_BYTES = 160 * 1024 * 1024 +export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 /** Transport-independent request handler consumed by the Host HTTP bridge. */ export interface FetchHandler { diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 35084918e8..a1764a3d58 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -57,7 +57,7 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] - /** Maximum buffered JSON body for every `/api` request. */ + /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ maxRequestBodyBytes?: number } diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 0b30ce6520..022436d558 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -11,6 +11,7 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' +import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' /** Structural webServer fake recording both route registries. */ function fakeHttpServer( @@ -90,6 +91,11 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ } describe('connection node half', () => { + it('reserves enough default carrier capacity for the 200 MiB image batch', () => { + expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024) + expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024) + }) + it('fails loud when the carrier cap cannot hold the configured image batch', () => { const ctx = new Context() const routes: WebRoute[] = [] From d65e2a9e8ada278607cbf3be6a078a51d652c337 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 21:46:59 +0800 Subject: [PATCH 051/248] test(images): close unified pipeline coverage gaps --- .../attachment/attachment-local/src/index.ts | 8 +- .../attachment-local/tests/encoding.spec.ts | 1 + .../tests/request-image.spec.ts | 17 +- packages/fs/tool-fs/tests/read-image.spec.ts | 27 ++ .../commands/tests/commands.spec.ts | 5 +- packages/llm/llm-deepseek/src/file-store.ts | 37 ++- packages/llm/llm-deepseek/src/files-api.ts | 5 +- packages/llm/llm-deepseek/src/serialize.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 37 ++- .../llm/llm-deepseek/tests/file-store.spec.ts | 278 +++++++++++++++++- .../llm/llm-deepseek/tests/files-api.spec.ts | 12 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 19 ++ packages/llm/llm-pi-ai/tests/config.spec.ts | 23 ++ packages/llm/llm-pi-ai/tests/context.spec.ts | 10 + packages/llm/llm/src/content.ts | 4 +- packages/llm/llm/tests/content.spec.ts | 68 ++++- packages/llm/llm/tests/service.spec.ts | 12 + 17 files changed, 526 insertions(+), 39 deletions(-) diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 516280548c..4fb200345b 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -93,7 +93,11 @@ class SharedRequest { wait(signal?: AbortSignal): Promise { signal?.throwIfAborted() this.waiters += 1 - if (signal === undefined) return this.promise.finally(() => this.release(false)) + if (signal === undefined) { + return this.promise.finally(() => { + this.release(false) + }) + } let released = false const release = (cancelled: boolean): void => { if (released) return @@ -113,6 +117,8 @@ class SharedRequest { }, (error: unknown) => { signal.removeEventListener('abort', abort) release(false) + // CompressionLimiter normalizes task rejections before this handler. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors reject(error) }) }) diff --git a/packages/attachment/attachment-local/tests/encoding.spec.ts b/packages/attachment/attachment-local/tests/encoding.spec.ts index d75fc9b4b3..8cd7a60540 100644 --- a/packages/attachment/attachment-local/tests/encoding.spec.ts +++ b/packages/attachment/attachment-local/tests/encoding.spec.ts @@ -83,6 +83,7 @@ describe('CompressionLimiter', () => { it('normalizes a non-Error rejection and releases its slot', async () => { const limiter = new CompressionLimiter(1) + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Native bindings can reject non-Error values. const failed = limiter.run(() => Promise.reject('native failure')) const next = limiter.run(() => Promise.resolve('next')) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index e33522c104..726837a242 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -340,7 +340,9 @@ describe('local request-image cache', () => { const read = vi.spyOn(attachments, 'readImage').mockImplementation((_ref, signal) => { readSignal = signal return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + signal?.addEventListener('abort', () => { + reject(new Error('request transform aborted', { cause: signal.reason })) + }, { once: true }) }) }) const controller = new AbortController() @@ -349,7 +351,9 @@ describe('local request-image cache', () => { { maxPixels: 640_000, maxBytes: 1024 * 1024 }, controller.signal, ) - await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => { + expect(read).toHaveBeenCalledTimes(1) + }) const reason = new Error('cancel only transform waiter') controller.abort(reason) @@ -369,7 +373,9 @@ describe('local request-image cache', () => { calls += 1 if (calls === 1) { return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + signal?.addEventListener('abort', () => { + reject(new Error('request transform aborted', { cause: signal.reason })) + }, { once: true }) }) } return actualRead(ref, signal) @@ -377,7 +383,9 @@ describe('local request-image cache', () => { const controller = new AbortController() const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } const cancelled = attachments.readImageRequest(master, policy, controller.signal) - await vi.waitFor(() => expect(calls).toBe(1)) + await vi.waitFor(() => { + expect(calls).toBe(1) + }) controller.abort('cancelled') const replacement = attachments.readImageRequest(master, policy) @@ -389,4 +397,5 @@ describe('local request-image cache', () => { await expect(replacement).resolves.toMatchObject({ width: 1130, height: 565 }) expect(calls).toBe(2) }) + }) diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 3bf86d27f5..03616911b5 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -251,6 +251,33 @@ describe('read_image_region', () => { expect(result.isError).toBe(false) }) + it('continues across an earlier session message without the requested image', async () => { + const ctx = await setup() + const source = await ctx.attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png' }) + const history = [ + createUserMessage({ + content: [{ type: 'text', text: 'before image' }], + source: { kind: 'plugin', plugin: 'test' }, + }), + createUserMessage({ + content: [{ type: 'image', attachment: source.ref }], + source: { kind: 'plugin', plugin: 'test' }, + }), + ] + + const result = await call(ctx, 'read_image_region', { + attachment_id: source.ref.attachmentId, + preview_width: 3, + preview_height: 3, + x: 0, + y: 0, + width: 1, + height: 1, + }, agentOn('vision-model', 'visual', history)) + + expect(result.isError).toBe(false) + }) + it('rejects a missing session, empty id, and invalid coordinate arguments', async () => { const ctx = await setup() const base = { diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index c65fb49bed..85806a1d36 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -487,9 +487,10 @@ describe('image attachments', () => { }) }), validateImageBatch(inputs: readonly unknown[]) { - return (AttachmentStore.prototype as unknown as { + const validate = AttachmentStore.prototype as unknown as { validateImageBatch(this: unknown, batch: readonly unknown[]): void - }).validateImageBatch.call(this, inputs) + } + validate.validateImageBatch.call(this, inputs) }, // The real base-class batch method over this double's limits and members. saveImages(inputs: readonly unknown[]) { diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts index ddaefd1eee..0757b42db2 100644 --- a/packages/llm/llm-deepseek/src/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -50,33 +50,44 @@ function abortReason(signal: AbortSignal): Error { : new Error('DeepSeek file upload cancelled with a non-Error reason.', { cause: reason }) } +function uploadFailure(error: unknown): Error { + return error instanceof Error + ? error + : new Error('DeepSeek file upload failed with a non-Error reason.', { cause: error }) +} + function waitForUpload(operation: SharedUpload, signal: AbortSignal | undefined): Promise { signal?.throwIfAborted() operation.waiters += 1 let released = false - const release = (cancelled: boolean): void => { + const release = (cancelledReason?: Error): void => { if (released) return released = true operation.waiters -= 1 - if (cancelled && operation.waiters === 0 && !operation.settled) { - operation.controller.abort(signal === undefined ? undefined : abortReason(signal)) + if (cancelledReason !== undefined && operation.waiters === 0 && !operation.settled) { + operation.controller.abort(cancelledReason) } } - if (signal === undefined) return operation.promise.finally(() => release(false)) + if (signal === undefined) { + return operation.promise.finally(() => { + release() + }) + } return new Promise((resolve, reject) => { const abort = (): void => { - release(true) - reject(abortReason(signal)) + const reason = abortReason(signal) + release(reason) + reject(reason) } signal.addEventListener('abort', abort, { once: true }) void operation.promise.then((value) => { signal.removeEventListener('abort', abort) - release(false) + release() resolve(value) }, (error: unknown) => { signal.removeEventListener('abort', abort) - release(false) - reject(error) + release() + reject(uploadFailure(error)) }) }) } @@ -155,7 +166,7 @@ export class DeepSeekFileStore { return value }, (error: unknown) => { shared.settled = true - throw error + throw uploadFailure(error) }) this.inflight.set(key, shared) void shared.promise.finally(() => { @@ -168,7 +179,7 @@ export class DeepSeekFileStore { version: RequestImageAttachment, connection: DeepSeekFileConnection, policy: DeepSeekFilePolicy, - signal?: AbortSignal, + signal: AbortSignal, ): Promise { if (version.bytes > MAX_CHAT_IMAGE_BYTES) { throw new LlmError('DeepSeek chat image exceeds the 32 MiB per-image limit.', 'INVALID_REQUEST') @@ -186,9 +197,9 @@ export class DeepSeekFileStore { mediaType: version.mediaType, filename: filename(version), expiresAfterSeconds: policy.expiresAfterSeconds, - ...signal === undefined ? {} : { signal }, + signal, }) - if (remote.bytes !== version.data.byteLength || remote.expiresAt === undefined) { + if (remote.bytes !== version.data.byteLength) { throw new LlmError('DeepSeek Files API upload response does not match the submitted image.', 'INVALID_RESPONSE') } return { diff --git a/packages/llm/llm-deepseek/src/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts index f19823100e..cc998b2e7e 100644 --- a/packages/llm/llm-deepseek/src/files-api.ts +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -143,7 +143,6 @@ export class DeepSeekFilesClient { let response: Response try { const headers = new Headers(attributionHeaders()) - for (const [name, value] of new Headers(init.headers)) headers.set(name, value) headers.set('authorization', `Bearer ${this.apiKey}`) response = await this.fetchImpl(`${this.baseURL}${path}`, { ...init, @@ -180,7 +179,7 @@ export class DeepSeekFilesClient { filename: string expiresAfterSeconds: number signal?: AbortSignal - }): Promise { + }): Promise { if (input.data.byteLength > MAX_FILE_UPLOAD_BYTES) { throw new LlmError('DeepSeek Files API upload exceeds 128 MiB.', 'INVALID_REQUEST') } @@ -197,7 +196,7 @@ export class DeepSeekFilesClient { const response = await this.request('/files', { method: 'POST', body: form }, input.signal) const file = parseFileObject(await response.json(), 'upload') if (file.expiresAt === undefined) throw invalidResponse('upload') - return file + return { ...file, expiresAt: file.expiresAt } } /** diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index a65c9750c3..b998b23a8b 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -317,7 +317,7 @@ export async function serializeMessagesWithImages( wire.push({ role: 'tool', tool_call_id: result.toolCallId, - content: text || (fileParts.length > 0 ? '(see attached image)' : '(no output)'), + content: text || '(no output)', }) pendingToolImages.push(...fileParts) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 731df2eb0f..2663c4cd78 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -176,6 +176,7 @@ describe('DeepSeekAdapter against a mock server', () => { await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', + tools: [{ name: 'read_image_region', description: 'crop', parameters: { type: 'object' } }], messages: [createUserMessage({ content: [ { type: 'text', text: 'describe ' }, @@ -191,7 +192,7 @@ describe('DeepSeekAdapter against a mock server', () => { role: 'user', content: [ { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}`) as string }, + { type: 'text', text: expect.stringContaining('Call read_image_region') as string }, { type: 'file', file_id: 'file-api-1' }, ], }], @@ -1499,6 +1500,23 @@ describe('plugin registration and config', () => { .toThrow(/maxTokens must be a positive integer/) }) + it('rejects image request limits on a text-only catalog model', () => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }], + })).toThrow(/text-only catalog model .* cannot declare image request limits/) + }) + + it.each([ + ['imagePixelBudget', 0, /imagePixelBudget must be a positive safe integer/], + ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be a positive safe integer/], + ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], + ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], + ] as const)('rejects per-model %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'vision', inputModalities: ['image'], [field]: value }], + })).toThrow(message) + }) + it('prefers a model\'s own output cap over the profile default', async () => { // The profile default stays what an unlisted or uncapped model resolves // to, so adding a per-model cap changes one model rather than the route. @@ -1569,6 +1587,23 @@ describe('plugin registration and config', () => { })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) }) + it.each([ + ['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/], + ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], + ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], + ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ] as const)('rejects %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message) + }) + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid request file bound %s', async (maxRequestFilesBytes) => { diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index 6d9e940786..069ff47c9d 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -4,8 +4,9 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { DeepSeekFileStore } from '../src/file-store.ts' -import { DeepSeekUploadIndex } from '../src/upload-index.ts' +import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/file-store.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const REF: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -90,7 +91,9 @@ describe('DeepSeekFileStore', () => { uploadSignal = init?.signal ?? undefined return new Promise((resolve, reject) => { complete = resolve - uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { once: true }) + uploadSignal?.addEventListener('abort', () => { + reject(new Error('upload aborted', { cause: uploadSignal?.reason })) + }, { once: true }) }) }) as typeof fetch const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl }) @@ -98,7 +101,9 @@ describe('DeepSeekFileStore', () => { const cancelled = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal) const completed = store.ensureUploaded(VERSION, CONNECTION, POLICY) - await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => { + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) const reason = new Error('cancel one upload waiter') controller.abort(reason) @@ -123,13 +128,17 @@ describe('DeepSeekFileStore', () => { const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { uploadSignal = init?.signal ?? undefined return new Promise((_resolve, reject) => { - uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { once: true }) + uploadSignal?.addEventListener('abort', () => { + reject(new Error('upload aborted', { cause: uploadSignal?.reason })) + }, { once: true }) }) }) as typeof fetch const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl }) const controller = new AbortController() const upload = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal) - await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => { + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) const reason = new Error('cancel only upload waiter') controller.abort(reason) @@ -138,6 +147,79 @@ describe('DeepSeekFileStore', () => { expect(uploadSignal?.reason).toBe(reason) }) + it('normalizes a non-Error cancellation reason', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => ( + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('upload aborted', { cause: init.signal?.reason })) + }, { once: true }) + }) + )) as typeof fetch + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, 'index.json')), + now: () => NOW, + fetch: fetchImpl, + }) + const controller = new AbortController() + const upload = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal) + await vi.waitFor(() => { + expect(fetchImpl).toHaveBeenCalledOnce() + }) + controller.abort('cancelled') + + await expect(upload).rejects.toMatchObject({ + message: 'DeepSeek file upload cancelled with a non-Error reason.', + cause: 'cancelled', + }) + }) + + it('starts a fresh upload while the cancelled transport is settling', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + let requests = 0 + const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { + requests += 1 + if (requests === 1) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + queueMicrotask(() => { + reject(new Error('upload aborted', { cause: init.signal?.reason })) + }) + }, { once: true }) + }) + } + return Promise.resolve(new Response(JSON.stringify({ + id: 'file-api-retry', object: 'file', bytes: 3, created_at: NOW / 1_000, + filename: 'dsh-retry.png', purpose: 'user_data', + expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + }), { status: 200 })) + }) as typeof fetch + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, 'index.json')), + now: () => NOW, + fetch: fetchImpl, + }) + const controller = new AbortController() + const cancelled = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal) + await vi.waitFor(() => { + expect(fetchImpl).toHaveBeenCalledOnce() + }) + controller.abort(new Error('cancel first')) + const retried = store.ensureUploaded(VERSION, CONNECTION, POLICY) + + await expect(cancelled).rejects.toThrow('cancel first') + await expect(retried).resolves.toMatchObject({ record: { fileId: 'file-api-retry' } }) + }) + + it('rejects a request version above the chat per-image limit before transport', async () => { + const fetchImpl = vi.fn() as typeof fetch + const store = new DeepSeekFileStore({ now: () => NOW, fetch: fetchImpl }) + const oversized = { ...VERSION, bytes: MAX_CHAT_IMAGE_BYTES + 1 } + await expect(store.ensureUploaded(oversized, CONNECTION, POLICY)) + .rejects.toMatchObject({ code: 'INVALID_REQUEST' }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + it('does not persist an upload whose response is missing and retries on the next request', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) @@ -158,6 +240,55 @@ describe('DeepSeekFileStore', () => { .resolves.toMatchObject({ record: { fileId: 'file-api-1' }, uploaded: true }) }) + it('rejects an upload response whose byte count differs from the request version', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000, + filename: 'dsh-wrong.png', purpose: 'user_data', + expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + }), { status: 200 }))) as typeof fetch + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, 'index.json')), + now: () => NOW, + fetch: fetchImpl, + }) + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)) + .rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) + }) + + it.each([ + ['image/jpeg', 'jpeg'], + ['image/webp', 'webp'], + ['image/gif', 'gif'], + ] as const)('uses the %s filename extension for uploads', async (mediaType, extension) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const remote = uploadFetch() + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, `${extension}.json`)), + now: () => NOW, + fetch: remote.fetchImpl, + }) + await store.ensureUploaded({ ...VERSION, mediaType }, CONNECTION, POLICY) + const form = vi.mocked(remote.fetchImpl).mock.calls[0]?.[1]?.body + expect(form).toBeInstanceOf(FormData) + const file = (form as FormData).get('file') + expect(file).toBeInstanceOf(File) + if (!(file instanceof File)) throw new Error('expected multipart file') + expect(file.name).toMatch(new RegExp(`\\.${extension}$`, 'u')) + }) + + it('normalizes a non-Error failure from the durable upload index', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + vi.spyOn(index, 'get').mockRejectedValue('index unavailable') + const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch }) + + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).rejects.toMatchObject({ + message: 'DeepSeek file upload failed with a non-Error reason.', + cause: 'index unavailable', + }) + }) + it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) @@ -190,6 +321,101 @@ describe('DeepSeekFileStore', () => { expect(remote.fetchImpl).toHaveBeenCalledTimes(2) }) + it('removes a losing upload and keeps the winning durable mapping when duplicate cleanup fails', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + vi.spyOn(index, 'commit').mockResolvedValue({ + accepted: false, + record: { + scope: deepSeekFileScope(CONNECTION.baseURL, CONNECTION.apiKey), + masterAttachmentId: VERSION.master.attachmentId, + variantId: VERSION.variantId, + fileId: DeepSeekFileId('file-api-winner'), + bytes: 3, + createdAt: NOW, + expiresAt: NOW + POLICY.expiresAfterSeconds * 1_000, + }, + }) + const remote = uploadFetch() + const fetchImpl = vi.fn((url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'DELETE') return Promise.resolve(new Response('failed', { status: 500 })) + return remote.fetchImpl(url, init) + }) as typeof fetch + const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl }) + + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).resolves.toMatchObject({ + record: { fileId: 'file-api-winner' }, + uploaded: false, + }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + }) + + it('reclaims one owned file after quota rejection and retries the upload once', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + let uploads = 0 + const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') { + uploads += 1 + if (uploads === 1) return Promise.resolve(new Response(JSON.stringify({ + error: { message: 'stored file quota exceeded', code: 'file_quota' }, + }), { status: 400 })) + return Promise.resolve(new Response(JSON.stringify({ + id: 'file-api-recovered', object: 'file', bytes: 3, created_at: NOW / 1_000, + filename: 'dsh-recovered.png', purpose: 'user_data', + expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + }), { status: 200 })) + } + if (init?.method === 'DELETE') { + return Promise.resolve(new Response(JSON.stringify({ + id: 'file-api-old', object: 'file', deleted: true, + }), { status: 200 })) + } + expect(new URL(requestUrl(input)).pathname).toBe('/files') + return Promise.resolve(new Response(JSON.stringify({ + object: 'list', + data: [{ + id: 'file-api-old', object: 'file', bytes: 3, created_at: NOW / 1_000, + filename: 'dsh-old.png', purpose: 'user_data', + }], + first_id: 'file-api-old', last_id: 'file-api-old', has_more: false, + }), { status: 200 })) + }) as typeof fetch + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, 'index.json')), + now: () => NOW, + fetch: fetchImpl, + }) + + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).resolves.toMatchObject({ + record: { fileId: 'file-api-recovered' }, uploaded: true, + }) + expect(uploads).toBe(2) + }) + + it('preserves a quota error when no harness-owned file can be reclaimed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({ + error: { message: 'file count quota exceeded', code: 'file_quota' }, + }), { status: 400 })) + return Promise.resolve(new Response(JSON.stringify({ + object: 'list', + data: [{ + id: 'file-api-foreign', object: 'file', bytes: 3, created_at: NOW / 1_000, + filename: 'foreign.png', purpose: 'user_data', + }], + has_more: false, + }), { status: 200 })) + }) as typeof fetch + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, 'index.json')), + now: () => NOW, + fetch: fetchImpl, + }) + + await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).rejects.toMatchObject({ code: 'FILES_API' }) + }) + it('finishes pagination before deleting cursor files during quota recovery', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) const deleted = new Set() @@ -227,4 +453,44 @@ describe('DeepSeekFileStore', () => { await expect(store.reclaimOldestOwned(CONNECTION, 2)).resolves.toBe(2) expect([...deleted]).toEqual(['file-api-oldest', 'file-api-next']) }) + + it('stops pagination when a page omits or repeats its cursor', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + for (const mode of ['missing', 'repeated'] as const) { + let page = 0 + const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'DELETE') { + const id = requestUrl(input).split('/').at(-1) + return Promise.resolve(new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 })) + } + page += 1 + const lastId = mode === 'missing' ? undefined : 'file-api-same' + return Promise.resolve(new Response(JSON.stringify({ + object: 'list', data: [], has_more: true, + ...lastId === undefined ? {} : { last_id: lastId }, + }), { status: 200 })) + }) as typeof fetch + const store = new DeepSeekFileStore({ + index: new DeepSeekUploadIndex(join(dir, `${mode}.json`)), + now: () => NOW, + fetch: fetchImpl, + }) + await expect(store.reclaimOldestOwned(CONNECTION, 1)).resolves.toBe(0) + expect(page).toBe(mode === 'missing' ? 1 : 2) + } + }) + + it('releases every batch and clears the scoped upload index', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + const index = new DeepSeekUploadIndex(join(dir, 'index.json')) + const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch }) + const reclaim = vi.spyOn(store, 'reclaimOldestOwned') + .mockResolvedValueOnce(1_000) + .mockResolvedValueOnce(2) + const clear = vi.spyOn(index, 'clear') + + await expect(store.releaseAll(CONNECTION)).resolves.toBe(1_002) + expect(reclaim).toHaveBeenCalledTimes(2) + expect(clear).toHaveBeenCalledOnce() + }) }) diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts index 466c9be83b..e6a1aa59ea 100644 --- a/packages/llm/llm-deepseek/tests/files-api.spec.ts +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -119,7 +119,7 @@ describe('DeepSeekFilesClient', () => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', - fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))) as typeof fetch, + fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))), }) await expect(client.retrieve(DeepSeekFileId('missing'))).rejects.toMatchObject({ name: 'DeepSeekFilesError', @@ -139,7 +139,7 @@ describe('DeepSeekFilesClient', () => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', - fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))) as typeof fetch, + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))), }) const error = await client.retrieve(DeepSeekFileId('missing')).catch((caught: unknown) => caught) expect(error).toBeInstanceOf(DeepSeekFilesError) @@ -151,7 +151,7 @@ describe('DeepSeekFilesClient', () => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', - fetch: vi.fn(() => Promise.reject(transport)) as typeof fetch, + fetch: vi.fn(() => Promise.reject(transport)), }) await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({ code: 'TRANSPORT', @@ -183,7 +183,7 @@ describe('DeepSeekFilesClient', () => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', - fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch, + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), }) await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) @@ -224,7 +224,7 @@ describe('DeepSeekFilesClient', () => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', - fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch, + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), }) await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) @@ -253,7 +253,7 @@ describe('DeepSeekFilesClient', () => { const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', - fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch, + fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), }) await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 8e04b9b3b0..06af757c9a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -399,6 +399,14 @@ describe('image serialization', () => { }) }) + it('rejects an image whose prepared request version is absent', async () => { + const ref = imageRef() + await expect(serializeMessagesWithImages([createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) + }) + it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { const messages = [ createUserMessage({ @@ -561,6 +569,17 @@ describe('image serialization', () => { expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ master: { mediaType: 'image/jpeg' } }) }) + it('rejects an unprepared image while computing exact request bytes', async () => { + const ref = imageRef() + await expect(serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) + }) + it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { const resolveFileId = vi.fn() await expect(serializeMessagesWithImages([createMessage({ diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts index 55444228de..61a2535a9d 100644 --- a/packages/llm/llm-pi-ai/tests/config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -64,3 +64,26 @@ describe('modality schema boundary', () => { expect(absent.providers['acme-gateway']?.defaultInput).toEqual(['text']) }) }) + +describe('request image policy bounds', () => { + it.each([ + ['requestImagePixelBudget', 0, /requestImagePixelBudget must be a positive safe integer/], + ['requestImagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /requestImagePixelBudget must be a positive safe integer/], + ['requestImageMaxBytes', 0, /requestImageMaxBytes must be a positive safe integer/], + ['requestImageMaxBytes', 1.5, /requestImageMaxBytes must be a positive safe integer/], + ] as const)('rejects %s=%s at service resolution', (field, value, message) => { + const programmatic = { + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm' }], + [field]: value, + }, + }, + } as unknown as Config + expect(() => { + assertServiceable(programmatic) + }).toThrow(message) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index a0fb671c95..8a3f7bd084 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -428,4 +428,14 @@ describe('pi-ai request context conversion', () => { history('assistant', [{ type: 'image', attachment: ref }]), )).toThrow(/assistant image output/) }) + + it('rejects an attachment service that omits a requested image version', async () => { + const store = { + readImageRequests: vi.fn(() => Promise.resolve([])), + } as unknown as AttachmentStore + await expect(toPiContext( + request([user([{ type: 'image', attachment: ref }])]), + store, + )).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) + }) }) diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 73a2aee889..72e452e005 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -74,7 +74,9 @@ function collectImageLengths( ): void { for (const block of blocks) { if (block.type === 'image') { - const bytes = policy.byteLength?.(block.attachment) ?? block.attachment.bytes + const bytes = policy.byteLength === undefined + ? block.attachment.bytes + : policy.byteLength(block.attachment) lengths.push(policy.representation === 'base64' ? base64Length(bytes) : bytes) } else if (block.type === 'tool-result') { collectImageLengths(block.content, lengths, policy) diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index d1b02fa011..6a0eb02c63 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages, offloadRequestImagesWithPolicy } from '../src/index.ts' +import { + CallId, + createUserMessage, + OFFLOADED_IMAGE_TEXT, + offloadRequestImages, + offloadRequestImagesWithPolicy, + projectImagesForTextModel, +} from '../src/index.ts' import type { ContentBlock } from '../src/index.ts' const source = { kind: 'plugin' as const, plugin: 'test' } @@ -19,6 +26,11 @@ function image(bytes: number): ContentBlock { } describe('offloadRequestImages', () => { + it('preserves every image when no payload bound is configured', () => { + const messages = [createUserMessage({ content: [image(300)], source })] + expect(offloadRequestImages(messages, undefined)).toBe(messages) + }) + it('preserves the original request when its base64 payload fits exactly', () => { const messages = [createUserMessage({ content: [image(3), image(3)], source })] expect(offloadRequestImages(messages, 8)).toBe(messages) @@ -116,4 +128,58 @@ describe('offloadRequestImagesWithPolicy', () => { expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20) expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581) }) + + it('uses route-owned request byte lengths when supplied', () => { + const messages = [createUserMessage({ content: [image(100), image(100)], source })] + const projected = offloadRequestImagesWithPolicy(messages, { + representation: 'raw', + maxBytes: 3, + byteLength: () => 2, + }) + expect(projected[0]?.content).toEqual([ + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + image(100), + ]) + }) +}) + +describe('projectImagesForTextModel', () => { + it('returns image-free history unchanged', () => { + const messages = [createUserMessage({ content: [{ type: 'text', text: 'plain' }], source })] + expect(projectImagesForTextModel(messages)).toBe(messages) + }) + + it('replaces direct and nested images while retaining unaffected messages and blocks', () => { + const plain = createUserMessage({ content: [{ type: 'text', text: 'plain' }], source }) + const nested = { + type: 'tool-result' as const, + toolCallId: CallId('nested-image'), + content: [{ type: 'text' as const, text: 'before' }, image(3), { type: 'text' as const, text: 'after' }], + } + const unchangedNested = { + type: 'tool-result' as const, + toolCallId: CallId('text-only'), + content: [{ type: 'text' as const, text: 'unchanged' }], + } + const visual = createUserMessage({ + content: [{ type: 'text', text: 'lead' }, image(3), unchangedNested, nested], + source, + }) + + const projected = projectImagesForTextModel([plain, visual]) + expect(projected[0]).toBe(plain) + expect(projected[1]?.content).toEqual([ + { type: 'text', text: 'lead' }, + { type: 'text', text: '[image omitted because this model accepts text only; attachment sha256:aaaaaaaa]' }, + unchangedNested, + { + ...nested, + content: [ + { type: 'text', text: 'before' }, + { type: 'text', text: '[image omitted because this model accepts text only; attachment sha256:aaaaaaaa]' }, + { type: 'text', text: 'after' }, + ], + }, + ]) + }) }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index b2e5399964..4c624fa862 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -986,6 +986,18 @@ describe('LlmRuntime', () => { type: 'text', text: '[image omitted because this model accepts text only; attachment sha256:aaaaaaaa]', }]) + + const frozen = Object.freeze({ + provider: 'route', + model: 'text-only', + messages: [createUserMessage({ + content: [{ type: 'image', attachment }], + source: { kind: 'plugin' as const, plugin: 'test' }, + })], + }) + await collect(ctx.llm.stream(frozen)) + expect(Object.isFrozen(seen[1])).toBe(true) + expect(Object.isFrozen(seen[1]?.messages)).toBe(true) }) it('passes cancellation through exact-model resolution', async () => { From 703ce4a3d626c2225bd85743c8a11495e4fa2a3c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 22:00:06 +0800 Subject: [PATCH 052/248] test(deepseek): expose Files API e2e failures --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index c52195433b..06ae9f6f4d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -178,7 +178,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () })], maxTokens: 100, }) - expect(result.finish.kind).toBe('stop') + expect(result.finish).toMatchObject({ kind: 'stop' }) expect(textOf(result).trim().length).toBeGreaterThan(0) expect(uploadedFile).toMatch(/^file-api-/u) } finally { From 0c9a664223060fc9dbcb22557f9b32e0680f5507 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 22:05:39 +0800 Subject: [PATCH 053/248] test(deepseek): print vision failure facts --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 06ae9f6f4d..3858f214a0 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -178,7 +178,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () })], maxTokens: 100, }) - expect(result.finish).toMatchObject({ kind: 'stop' }) + expect( + result.finish.kind, + `DeepSeek vision result: ${JSON.stringify(result.finish)}`, + ).toBe('stop') expect(textOf(result).trim().length).toBeGreaterThan(0) expect(uploadedFile).toMatch(/^file-api-/u) } finally { From 724783b02480e0e926e17f00d92c59f1b59228f6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 12:30:47 +0800 Subject: [PATCH 054/248] refactor(image): remove region reads --- ...26-08-10-minimal-read-image-tool.i18n.yaml | 4 +- .../2026-08-10-minimal-read-image-tool.md | 4 +- .../2026-08-10-minimal-read-image-tool.zh.md | 4 +- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 14 +- ...08-20-unified-image-request-pipeline.zh.md | 14 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 39 +--- docs/subsystems/attachment.zh.md | 39 +--- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 57 +---- docs/tool-catalog.zh.md | 55 +---- examples/acp-agent/tests/acp.snapshot.ts | 7 +- .../system-prompt.expected.md | 42 +--- .../read-image/tool-schemas.expected.json | 48 +--- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/index.ts | 25 +- .../attachment-local/src/request-image.ts | 94 +------- .../tests/request-image.spec.ts | 75 +----- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 4 +- packages/attachment/attachment/README.zh.md | 4 +- packages/attachment/attachment/src/index.ts | 23 -- packages/attachment/attachment/src/types.ts | 24 +- .../attachment/attachment/tests/index.spec.ts | 9 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 18 +- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 17 +- packages/fs/tool-fs/README.zh.md | 17 +- packages/fs/tool-fs/src/read-image.ts | 150 +----------- packages/fs/tool-fs/tests/read-image.spec.ts | 220 +----------------- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/README.zh.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 1 - packages/llm/llm-deepseek/src/serialize.ts | 9 +- packages/llm/llm-deepseek/src/upload-index.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 3 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 31 +-- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/context.ts | 12 +- packages/llm/llm-pi-ai/tests/context.spec.ts | 11 - packages/llm/llm/src/content.ts | 13 +- scripts/gen-cordis-catalog.ts | 1 - scripts/gen-tool-catalog.ts | 6 +- scripts/type-equiv.manifest.json | 10 - 54 files changed, 132 insertions(+), 1040 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml index 6c37530274..b0c70c4df4 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md -2026-08-10-minimal-read-image-tool.md: 0c0c6a95fa3d8be1dbe895ecd83ff44e1e1eac17 -2026-08-10-minimal-read-image-tool.zh.md: c3c2fe1095637a19c3ebaa21cf23a501fe83c480 +2026-08-10-minimal-read-image-tool.md: 19306a35fe709a04d94090a62056575b4d51f7bc +2026-08-10-minimal-read-image-tool.zh.md: c7562c433e909d1f81361c0ced56318795e6469e diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md index 0c0c6a95fa..19306a35fe 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -6,14 +6,13 @@ English | [中文](2026-08-10-minimal-read-image-tool.zh.md) ## Problem -The multimodal attachment work gave user uploads a complete durable path, but the model itself had no way to inspect an image on disk or crop a durable user upload that had no path. `read` rejects binary content by contract, so an agent asked about a screenshot or rendered chart either failed or used a lossy workaround. A standalone attempt in PR #598 combined the tool with loop-level route scoping, per-route schema visibility, and new session-log concepts. Those features were not required to publish a logged image tool result. +The multimodal attachment work gave user uploads a complete durable path, but the model itself had no way to inspect an image on disk. `read` rejects binary content by contract, so an agent asked about a screenshot or rendered chart either failed or used a lossy workaround. A standalone attempt in PR #598 combined the tool with loop-level route scoping, per-route schema visibility, and new session-log concepts. Those features were not required to publish a logged image tool result. ## Decision Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged tool results over existing extension points. - **`read_image` reads a filesystem path.** Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`. The tool result contains metadata and an `ImageBlock`. -- **`read_image_region` crops a durable session attachment.** The request names the complete attachment id, current preview dimensions, and a preview-coordinate rectangle. The tool authorizes the id against images already referenced by the calling session, maps the rectangle to the durable master, crops that master, and persists the result as a new attachment. Its result contains the cropped `ImageBlock`, so the model-visible crop is reconstructable from the log. This is the path for pasted or dragged images that have no filesystem location. - **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`). - **Registration is composition-conditional, execution is route-gated.** The tools register only under `ctx.inject(['attachments'], …)`. Before I/O, the strict gate resolves the calling route through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A text-only route can still consume prior durable images because the shared LLM runtime projects them to placeholders at request assembly. - **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. @@ -29,6 +28,5 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged ## Consequences - The tools refuse execution on a text-only route, while existing images in session history are represented by request-local placeholders. -- Pasted and dragged images can be cropped without exposing local paths. Session reference authorization prevents access to attachments outside the current session. - Repeated image results accumulate request cost until request projection or compaction removes them; content addressing deduplicates durable bytes. - The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages. diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md index c3c2fe1095..c7562c433e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -6,14 +6,13 @@ Status: implemented ## 问题 -多模态附件工作为用户上传建立了完整的持久路径,但模型无法查看磁盘图片,也无法裁剪没有文件路径的持久用户上传。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么使用有损的变通方法。PR #598 的独立尝试把工具与循环级路由作用域、按路由控制 schema 可见性和新的会话日志概念放在一起。这些能力不是发布一条带图片且已记录的工具结果所必需的。 +多模态附件工作为用户上传建立了完整的持久路径,但模型无法查看磁盘图片。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么使用有损的变通方法。PR #598 的独立尝试把工具与循环级路由作用域、按路由控制 schema 可见性和新的会话日志概念放在一起。这些能力不是发布一条带图片且已记录的工具结果所必需的。 ## 决定 两个图片读取操作都放在 `dsh-tool-fs`,通过现有扩展点发布普通的持久工具结果。 - **`read_image` 读取文件系统路径。** 扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型,附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动。工具结果包含元数据和一个 `ImageBlock`。 -- **`read_image_region` 裁剪会话中的持久附件。** 请求给出完整附件 ID、当前预览尺寸和预览坐标矩形。工具根据当前会话已引用的图片授权该 ID,把矩形映射到持久主版本,从主版本裁剪,并把结果保存为新附件。结果包含裁剪后的 `ImageBlock`,因此模型可见裁剪可以从日志重建。这也是粘贴或拖入且没有文件路径的图片所使用的入口。 - **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上,任何后端都无法无界缓冲文件;stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。 - **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册。执行时在 I/O 之前通过 `ctx.llm.resolveModelInfo` 解析调用路由,并要求 `inputModalities` 包含 `image`;能力未知即拒绝。纯文本路由仍可使用此前的持久图片,因为共享 LLM 运行时会在请求组装时把图片投影为占位符。 - **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 @@ -29,6 +28,5 @@ Status: implemented ## 后果 - 工具在纯文本路由上拒绝执行,而会话历史中已经存在的图片会由请求期占位符表示。 -- 粘贴和拖入的图片无需暴露本地路径即可裁剪。会话引用授权会阻止访问当前会话范围外的附件。 - 重复的图片结果会累积请求成本,直到请求投影或压缩将其移除;内容寻址只去重持久字节。 - 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index a721138585..e95c7faa2a 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: c4af375d94ebf2b52fbdd0e8d3d4ee715f87f50e -2026-08-20-unified-image-request-pipeline.zh.md: a1e10c63804b42da127bd115c35587191f0a60f0 +2026-08-20-unified-image-request-pipeline.md: f0ef01de3b22c7132e7f698d0948a0da945726ba +2026-08-20-unified-image-request-pipeline.zh.md: b1a14ac418987ab8bfee9b731ad38cb48e21753e diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index c4af375d94..f0ef01de3b 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -6,7 +6,7 @@ English | [中文](2026-08-20-unified-image-request-pipeline.zh.md) ## Problem -Durable image history, provider resolution, inline request size, and remote file reuse have different limits. Treating an admitted image as the bytes sent on every later request forced one byte cap and one raster to serve all four concerns. Large but ordinary input was refused, clean 16-bit PNG could pass into history and fail at DeepSeek, repeated base64 expanded long requests, and a provider rejection repeated because the same durable image stayed in every future request. A model also had no stable way to crop a user upload that had no filesystem path. +Durable image history, provider resolution, inline request size, and remote file reuse have different limits. Treating an admitted image as the bytes sent on every later request forced one byte cap and one raster to serve all four concerns. Large but ordinary input was refused, clean 16-bit PNG could pass into history and fail at DeepSeek, repeated base64 expanded long requests, and a provider rejection repeated because the same durable image stayed in every future request. ## Decision @@ -24,13 +24,13 @@ Batch admission prepares and verifies every master once before publishing any me `AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 master projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. -The `variantId` and cache path cover the master attachment id, transform version, route pixel and byte budgets, optional master-coordinate crop, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the master byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. `AttachmentStore.readImageRequests` preserves input order while the local implementation runs master and request transforms through one FIFO limiter. `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every master has been prepared. +The `variantId` and cache path cover the master attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the master byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. `AttachmentStore.readImageRequests` preserves input order while the local implementation runs master and request transforms through one FIFO limiter. `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every master has been prepared. Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(masterBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained masters are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. -### Stable handles and master-coordinate crops +### Stable handles -Every retained request image is preceded by its complete attachment id and actual request dimensions. When the active request exposes `read_image_region`, the text also supplies its preview-coordinate arguments. The tool accepts only an attachment already referenced by the calling session. It maps the supplied preview rectangle to the 2048px master with floor-at-origin and ceil-at-far-edge rounding, crops the master rather than the preview, and persists the result as a new attachment. The tool result contains the new `ImageBlock`, so model-visible output and the durable log remain equivalent. +Every retained request image is preceded by its complete attachment id and actual request dimensions. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. ### DeepSeek Files lifecycle @@ -46,7 +46,7 @@ Historical attachment objects that later disappear or fail integrity verificatio ## Alternatives considered -**Use one 1MiB canonical image for storage and requests.** This makes model resolution determine durable quality, reduces the source for later crops, and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent master and request policies keep those responsibilities explicit. +**Use one 1MiB canonical image for storage and requests.** This makes model resolution determine durable image detail and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent master and request policies keep those responsibilities explicit. **Reject images above provider dimensions or at the encoding quality floor.** A provider limit is route-specific and future requests may use another model. Proportional master preparation and request projection accept ordinary large images while bounding each later representation. @@ -56,15 +56,13 @@ Historical attachment objects that later disappear or fail integrity verificatio **Trust a locally indexed file id indefinitely.** Remote expiry, deletion, and lost upload responses make local and provider state diverge. Response-directed invalidation and one re-upload recover without an unbounded retry loop; an ambiguous stale-file response must invalidate every file used by that attempt because it provides no safe exact target. -**Crop the request preview.** Repeated crops would compound the 640,000-pixel reduction and make coordinates depend on previous encodes. Mapping back to the master preserves the available local detail. - **Refuse text-only model selection after any image.** Durable history can outlive the model that first consumed it. Request-local placeholders keep the session usable without rewriting history. **Remove one image whenever a request crosses its limit.** That changes an early request message after nearly every new upload. Quantized removed prefixes keep cache invalidation occasional while honoring the configured high bound. ## Verification -Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, map preview crops to the master, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index a1e10c6380..b1a14ac418 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -持久图片历史、提供方分辨率、内联请求大小和远端文件复用有不同限制。过去把已接纳图片直接作为之后每次请求发送的字节,导致一个字节上限和一份光栅同时承担四种职责。普通大图会被拒绝;干净的 16-bit PNG 可以进入历史,之后才被 DeepSeek 拒绝;重复 base64 使长会话请求持续增长;提供方拒绝后,同一持久图片还会进入每次后续请求。模型也无法稳定裁剪没有文件系统路径的用户上传图片。 +持久图片历史、提供方分辨率、内联请求大小和远端文件复用有不同限制。过去把已接纳图片直接作为之后每次请求发送的字节,导致一个字节上限和一份光栅同时承担四种职责。普通大图会被拒绝;干净的 16-bit PNG 可以进入历史,之后才被 DeepSeek 拒绝;重复 base64 使长会话请求持续增长;提供方拒绝后,同一持久图片还会进入每次后续请求。 ## Decision @@ -24,13 +24,13 @@ Status: implemented `AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 主版本在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 -`variantId` 和缓存路径覆盖主附件 ID、变换策略版本、路由像素和字节预算、可选的主版本坐标裁剪区域及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用主版本字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。`AttachmentStore.readImageRequests` 保持输入顺序,本地实现则通过一个 FIFO 限流器运行主版本和请求版本变换。`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部主版本准备完成后,批次仍按顺序发布。 +`variantId` 和缓存路径覆盖主附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用主版本字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。`AttachmentStore.readImageRequests` 保持输入顺序,本地实现则通过一个 FIFO 限流器运行主版本和请求版本变换。`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部主版本准备完成后,批次仍按顺序发布。 请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(主版本字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的主版本,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 -### 稳定句柄与主版本坐标裁剪 +### 稳定句柄 -每张保留请求图片前都有完整附件 ID 和实际请求尺寸。当前请求公开 `read_image_region` 时,这段文本还会提供预览坐标参数。该工具只接受调用会话已经引用的附件。它按起点向下取整、远端边界向上取整,把提交的预览矩形映射到 2048px 主版本,从主版本而非预览图裁剪,并把结果保存为新附件。工具结果包含新的 `ImageBlock`,因此模型可见输出与持久日志保持一致。 +每张保留请求图片前都有完整附件 ID 和实际请求尺寸。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。 ### DeepSeek Files 生命周期 @@ -46,7 +46,7 @@ Status: implemented ## Alternatives considered -**使用一份 1MiB 规范图片同时负责存储和请求。** 这种做法让模型分辨率决定持久质量,降低之后裁剪可用的源信息,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的主版本和请求策略会明确区分这些职责。 +**使用一份 1MiB 规范图片同时负责存储和请求。** 这种做法让模型分辨率决定持久图片细节,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的主版本和请求策略会明确区分这些职责。 **拒绝超过提供方尺寸或达到编码质量下限的图片。** 提供方限制属于具体路由,未来请求可能改用另一个模型。按比例准备主版本和投影请求版本可以接纳普通大图,同时约束每种后续表示。 @@ -56,15 +56,13 @@ Status: implemented **永久信任本地索引中的文件 ID。** 远端过期、删除和上传响应丢失会使本地与提供方状态不一致。按响应失效和一次重新上传可以恢复,同时避免无界重试;响应没有给出可安全使用的精确目标时,必须使该次请求使用的全部文件失效。 -**从请求预览图裁剪。** 重复裁剪会叠加 640,000 像素缩小,坐标也会依赖之前的编码。映射回主版本能保留本地可用细节。 - **历史中出现图片后拒绝选择纯文本模型。** 持久历史可能比最初读取它的模型存活更久。按请求生成的占位文本可以保持会话可用,无需改写历史。 **请求每次越过上限就移除一张图片。** 这种做法会在几乎每次新增图片后改写较早的请求消息。按固定步长递增的移除前缀会降低缓存失效频率,同时遵守配置的上限。 ## Verification -包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、预览到主版本坐标映射、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 ## Consequences diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 804d5dd86a..276fad138a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 661e9a50200fd5c650c389d9bb631c04de61d228 -config-catalog.zh.md: 4299bccc1f59899bd78fd64f915c784e26eea49d +config-catalog.md: d288fe3b85f1599da6ecef3dcf59c04c4e8c85d5 +config-catalog.zh.md: 266465fd09312c5dde9df4453c34f3aa774db7e2 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 661e9a5020..d288fe3b85 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -346,7 +346,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4299bccc1f..266465fd09 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -348,7 +348,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index e391a3aa27..ee14a0698f 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: ea15172e3e1fafec2e09c3bedc2590fc7551eb2e -attachment.zh.md: c04114c9691fa1ba03446f903c4baf5ae021da4c +attachment.md: 7c55bc192088f67ae7d117bc150aa0ea6fdf8b09 +attachment.zh.md: d5a140e283c1b7aa6ee5c991c2932ff65de0b88e diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 99d4ba7682..7c55bc1920 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -89,16 +89,6 @@ interface StoredImageAttachment { } ``` -```ts type-equiv -/** Pixel rectangle in the oriented 2048px master-version coordinate system. */ -interface MasterImageCrop { - x: number - y: number - width: number - height: number -} -``` - ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -106,27 +96,13 @@ interface ImageRequestPolicy { maxPixels: number /** Encoded-byte cap before base64 expansion or Files API upload. */ maxBytes: number - /** Optional master-coordinate crop applied before pixel-budget scaling. */ - crop?: MasterImageCrop -} -``` - -```ts type-equiv -/** Crop coordinates measured by a model on the request preview it received. */ -interface PreviewImageCrop { - previewWidth: number - previewHeight: number - x: number - y: number - width: number - height: number } ``` ```ts type-equiv /** Cached request version derived from one provider-independent master attachment. */ interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, crop, and fixed encoder parameters. */ + /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ variantId: ImageVariantId /** Durable master reference from which this request version was derived. */ master: ImageAttachmentRef @@ -142,12 +118,10 @@ interface RequestImageAttachment { space: 'srgb' /** Whether the encoded request version retains an alpha channel. */ hasAlpha: boolean - /** Applied master-coordinate crop, when present. */ - crop?: MasterImageCrop } ``` -`saveImage()` prepares a provider-independent 2048px, 4MiB master and atomically commits it before returning its reference. `saveImages()` prepares every validated master once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a master from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. `readImageRequests()` lets an implementation apply its configured transform concurrency to an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and defaults to two simultaneous transformations. `cropImage()` maps model preview coordinates back to the master and returns another durable attachment. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares a provider-independent 2048px, 4MiB master and atomically commits it before returning its reference. `saveImages()` prepares every validated master once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a master from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. `readImageRequests()` lets an implementation apply its configured transform concurrency to an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -217,15 +191,6 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: * @returns request versions in the same order as `refs`. */ async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise - -/** - * Crop the stored master by coordinates measured on a model request preview and persist the result. - * @param ref - session-authorized master attachment. - * @param crop - preview dimensions and preview-coordinate rectangle. - * @param signal - optional cancellation. - * @returns a new durable attachment reference suitable for a logged tool result. - */ -cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index d235c9ed2e..d5a140e283 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -89,16 +89,6 @@ interface StoredImageAttachment { } ``` -```ts type-equiv -/** Pixel rectangle in the oriented 2048px master-version coordinate system. */ -interface MasterImageCrop { - x: number - y: number - width: number - height: number -} -``` - ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -106,27 +96,13 @@ interface ImageRequestPolicy { maxPixels: number /** Encoded-byte cap before base64 expansion or Files API upload. */ maxBytes: number - /** Optional master-coordinate crop applied before pixel-budget scaling. */ - crop?: MasterImageCrop -} -``` - -```ts type-equiv -/** Crop coordinates measured by a model on the request preview it received. */ -interface PreviewImageCrop { - previewWidth: number - previewHeight: number - x: number - y: number - width: number - height: number } ``` ```ts type-equiv /** Cached request version derived from one provider-independent master attachment. */ interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, crop, and fixed encoder parameters. */ + /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ variantId: ImageVariantId /** Durable master reference from which this request version was derived. */ master: ImageAttachmentRef @@ -142,12 +118,10 @@ interface RequestImageAttachment { space: 'srgb' /** Whether the encoded request version retains an alpha channel. */ hasAlpha: boolean - /** Applied master-coordinate crop, when present. */ - crop?: MasterImageCrop } ``` -`saveImage()` 准备提供方无关的 2048px、4MiB 主版本,并在返回引用前以原子方式提交。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的主版本,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的主版本。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。`readImageRequests()` 允许实现按自身配置的变换并发处理有序批次。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,默认同时执行两项变换。`cropImage()` 把模型预览坐标映射回主版本,并返回另一个持久附件。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备提供方无关的 2048px、4MiB 主版本,并在返回引用前以原子方式提交。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的主版本,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的主版本。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。`readImageRequests()` 允许实现按自身配置的变换并发处理有序批次。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,默认同时执行两项变换。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -217,15 +191,6 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: * @returns request versions in the same order as `refs`. */ async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise - -/** - * Crop the stored master by coordinates measured on a model request preview and persist the result. - * @param ref - session-authorized master attachment. - * @param crop - preview dimensions and preview-coordinate rectangle. - * @param signal - optional cancellation. - * @returns a new durable attachment reference suitable for a logged tool result. - */ -cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index d219a4c8ad..10447d6107 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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 docs/tool-catalog.md -tool-catalog.md: 11a7aead7938fca40d20096e3689890258fbe31c -tool-catalog.zh.md: f29d489441b36318523e0afa2eeab9104e639fd0 +tool-catalog.md: 1fa650f1e4e025274d069f27a6522abff46af2e2 +tool-catalog.zh.md: c3209e7007e9cf05770ccee0698f9e98a32e8363 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 11a7aead79..1fa650f1e4 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -24,7 +24,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. | -| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `read_image_region`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (image-tool registration)`, `ctx.llm + an image-capable route (image-tool execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image and read_image_region)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tools are not registered without `ctx.attachments`; their schemas are route-independent, and execution refuses unless the exact routed model declares image input. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (image-tool registration)`, `ctx.llm + an image-capable route (image-tool execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tool is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-terminal` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.terminals`, `ctx.systemPrompt`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot shell/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.jobs`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | @@ -695,7 +695,7 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts ### `read_image` -Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input. +Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input. ```json { @@ -714,57 +714,6 @@ Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -### `read_image_region` - -Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image. - -```json -{ - "type": "object", - "properties": { - "attachment_id": { - "type": "string", - "description": "Complete attachment id shown beside the image." - }, - "preview_width": { - "type": "integer", - "description": "Width of the preview shown to the model." - }, - "preview_height": { - "type": "integer", - "description": "Height of the preview shown to the model." - }, - "x": { - "type": "integer", - "description": "Left edge in preview pixels." - }, - "y": { - "type": "integer", - "description": "Top edge in preview pixels." - }, - "width": { - "type": "integer", - "description": "Crop width in preview pixels." - }, - "height": { - "type": "integer", - "description": "Crop height in preview pixels." - } - }, - "required": [ - "attachment_id", - "preview_width", - "preview_height", - "x", - "y", - "width", - "height" - ] -} -``` - -Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) - ### `write` Create or fully replace a UTF-8 text file. @@ -791,7 +740,7 @@ Create or fully replace a UTF-8 text file. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tools are not registered without `ctx.attachments`; their schemas are route-independent, and execution refuses unless the exact routed model declares image input. +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tool is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index f29d489441..c3209e7007 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -28,7 +28,7 @@ | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | -| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`read_image_region`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (image-tool registration)`、`ctx.llm + an image-capable route (image-tool execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image and read_image_region)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。 | +| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (image-tool registration)`、`ctx.llm + an image-capable route (image-tool execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时图片工具不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图片输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | | `@deepseek-ai/dsh-tool-terminal` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.terminals`、`ctx.systemPrompt`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.jobs`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 | | `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 | @@ -701,7 +701,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `read_image` -读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。要求当前模型接受图像输入。 +读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此仅为查看图片时应直接使用此工具,无需安装图片库或创建缩略图。可以用小批次并发读取彼此独立的文件。要求当前模型接受图像输入。 ```json { @@ -720,57 +720,6 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -### `read_image_region` - -裁剪当前会话中模型已经可见的图片附件。坐标采用该图片旁给出的预览尺寸。 - -```json -{ - "type": "object", - "properties": { - "attachment_id": { - "type": "string", - "description": "Complete attachment id shown beside the image." - }, - "preview_width": { - "type": "integer", - "description": "Width of the preview shown to the model." - }, - "preview_height": { - "type": "integer", - "description": "Height of the preview shown to the model." - }, - "x": { - "type": "integer", - "description": "Left edge in preview pixels." - }, - "y": { - "type": "integer", - "description": "Top edge in preview pixels." - }, - "width": { - "type": "integer", - "description": "Crop width in preview pixels." - }, - "height": { - "type": "integer", - "description": "Crop height in preview pixels." - } - }, - "required": [ - "attachment_id", - "preview_width", - "preview_height", - "x", - "y", - "width", - "height" - ] -} -``` - -来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) - ### `write` 创建或完全替换 UTF-8 文本文件。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 8da7ac71b1..548b4025a4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -807,8 +807,7 @@ it('pins native DeepSeek Files image offload in the request sent by the assemble { type: 'text', text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' - + 'preview 1x1px. Crop coordinates use this preview. Call read_image_region with this attachment_id, ' - + 'preview_width=1, preview_height=1, x, y, width, and height.', + + 'request image 1x1px.', }, { type: 'file', file_id: 'file-api-snapshot-1' }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, @@ -852,9 +851,7 @@ it('pins native DeepSeek Files image offload in the request sent by the assemble role: 'tool', tool_call_id: 'native-read-image', content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; preview 1x1px. ' - + 'Crop coordinates use this preview. Call read_image_region with this attachment_id, preview_width=1, ' - + 'preview_height=1, x, y, width, and height.', + + '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; request image 1x1px.', }, { role: 'user', diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index 7408ddb329..678de3e53f 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -125,28 +125,11 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input. */ + /** Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input. */ read_image: { /** Path to the image file, resolved by the filesystem backend. */ file_path: string; } & Record; - /** Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image. */ - read_image_region: { - /** Complete attachment id shown beside the image. */ - attachment_id: string; - /** Width of the preview shown to the model. */ - preview_width: number; - /** Height of the preview shown to the model. */ - preview_height: number; - /** Left edge in preview pixels. */ - x: number; - /** Top edge in preview pixels. */ - y: number; - /** Crop width in preview pixels. */ - width: number; - /** Crop height in preview pixels. */ - height: number; - } & Record; /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ @@ -384,29 +367,6 @@ interface ToolOutputMap { sourceHeight?: number; }; }; - read_image_region: { - sourceAttachmentId: string; - preview: { - width: number; - height: number; - }; - crop: { - x: number; - y: number; - width: number; - height: number; - }; - image: { - attachmentId: string; - mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif"; - bytes: number; - width: number; - height: number; - name?: string; - sourceWidth?: number; - sourceHeight?: number; - }; - }; send_message: { messageId: string; }; diff --git a/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json index dec4bd85ab..fa8862c09a 100644 --- a/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json @@ -246,7 +246,7 @@ }, { "name": "read_image", - "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", "parameters": { "type": "object", "properties": { @@ -260,52 +260,6 @@ ] } }, - { - "name": "read_image_region", - "description": "Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image.", - "parameters": { - "type": "object", - "properties": { - "attachment_id": { - "type": "string", - "description": "Complete attachment id shown beside the image." - }, - "preview_width": { - "type": "integer", - "description": "Width of the preview shown to the model." - }, - "preview_height": { - "type": "integer", - "description": "Height of the preview shown to the model." - }, - "x": { - "type": "integer", - "description": "Left edge in preview pixels." - }, - "y": { - "type": "integer", - "description": "Top edge in preview pixels." - }, - "width": { - "type": "integer", - "description": "Crop width in preview pixels." - }, - "height": { - "type": "integer", - "description": "Crop height in preview pixels." - } - }, - "required": [ - "attachment_id", - "preview_width", - "preview_height", - "x", - "y", - "width", - "height" - ] - } - }, { "name": "send_message", "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index d15a1fd01e..a8bfa1b322 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: 6141b7559492aa4c50831c8124a917bfdb704f4b -README.zh.md: 2a8ed6e1aef8022aba5053bf1ef0f9728340d086 +README.md: d4831f864dbb061319008242395e2c8ff6d9f642 +README.zh.md: 45bddf47ea5f68c15778040de5b29817e8f62956 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 6141b75594..d4831f864d 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -6,7 +6,7 @@ The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachmen Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent master. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `masterMaxDimension` (2048px by default). The master has its own `masterMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both master limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and a converted master are each fully decoded once. `saveImages` prepares and verifies every master once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored master under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It also executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the master id, transform version, pixel and byte budgets, optional master-coordinate crop, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. `readImageRequests` schedules batches through the service's FIFO limiter. `imageCompressionConcurrency` controls simultaneous master and request transforms from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `cropImage` maps coordinates measured on a model preview back to the master, crops the master rather than the preview, and commits the crop as another durable attachment. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored master under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It also executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the master id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. `readImageRequests` schedules batches through the service's FIFO limiter. `imageCompressionConcurrency` controls simultaneous master and request transforms from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 2a8ed6e1ae..45bddf47ea 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -6,7 +6,7 @@ 每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的主版本:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`(默认 2048px)。主版本有独立的 `masterMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的主版本各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次主版本,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 -请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的主版本缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选仍按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含主版本 ID、变换策略版本、像素和字节预算、可选的主版本坐标裁剪区域以及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。`readImageRequests` 通过服务的 FIFO 限流器调度批次。`imageCompressionConcurrency` 控制同时执行的主版本和请求版本变换,范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。`cropImage` 把模型在预览图上测得的坐标映射回主版本,从主版本而非预览图裁剪,并把裁剪结果提交为另一个持久附件。 +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的主版本缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选仍按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含主版本 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。`readImageRequests` 通过服务的 FIFO 限流器调度批次。`imageCompressionConcurrency` 控制同时执行的主版本和请求版本变换,范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 4fb200345b..9007544047 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -8,7 +8,6 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, - PreviewImageCrop, RequestImageAttachment, SaveImageAttachment, SavedImageAttachment, @@ -18,13 +17,13 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { MasterImagePolicy } from './canonical.ts' import { CompressionLimiter } from './compression-limiter.ts' import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts' -import { previewCropToMaster, readRequestImageFile, requestImageVariantId } from './request-image.ts' +import { readRequestImageFile, requestImageVariantId } from './request-image.ts' export { isMasterImage, prepareMasterImage } from './canonical.ts' export type { MasterImage, MasterImagePolicy } from './canonical.ts' export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts' export type { PreparedImageFile } from './store.ts' -export { previewCropToMaster, readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts' +export { readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts' /** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024 @@ -255,26 +254,6 @@ export class LocalAttachmentStore extends AttachmentStore { return operation.wait(signal) } - override async cropImage( - ref: ImageAttachmentRef, - crop: PreviewImageCrop, - signal?: AbortSignal, - ): Promise { - const master = await this.readImage(ref, signal) - const region = previewCropToMaster(ref.width, ref.height, crop) - const version = await this.requestVersion(ref, { - maxPixels: region.width * region.height, - maxBytes: this.masterPolicy.maxBytes, - crop: region, - }, master, signal) - signal?.throwIfAborted() - const stem = ref.name?.replace(/\.[^.]+$/u, '') ?? String(ref.attachmentId).slice(0, 15) - return this.saveImage({ - data: version.data, - mediaType: version.mediaType, - name: `${stem}-crop.${version.mediaType.slice('image/'.length).replace('jpeg', 'jpg')}`, - }) - } } export default LocalAttachmentStore diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index ef7c841bed..c37a473d4c 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -1,4 +1,4 @@ -/** Deterministic cached image versions for model requests and region reads. */ +/** Deterministic cached image versions for model requests. */ import { createHash, randomUUID } from 'node:crypto' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' @@ -9,8 +9,6 @@ import type { ImageMediaType, ImageAttachmentRef, ImageRequestPolicy, - MasterImageCrop, - PreviewImageCrop, RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -80,22 +78,6 @@ function checkedInteger(value: number, name: string): number { function validatePolicy(policy: ImageRequestPolicy): void { checkedInteger(policy.maxPixels, 'Image request maxPixels') checkedInteger(policy.maxBytes, 'Image request maxBytes') - if (policy.crop !== undefined) { - if (!Number.isSafeInteger(policy.crop.x) || policy.crop.x < 0 - || !Number.isSafeInteger(policy.crop.y) || policy.crop.y < 0) { - throw new AttachmentError('Image crop origin must use non-negative integer pixels.', 'INVALID_ATTACHMENT_REF') - } - checkedInteger(policy.crop.width, 'Image crop width') - checkedInteger(policy.crop.height, 'Image crop height') - } -} - -function checkedCrop(master: StoredImageAttachment, crop: MasterImageCrop | undefined): MasterImageCrop | undefined { - if (crop === undefined) return undefined - if (crop.x + crop.width > master.ref.width || crop.y + crop.height > master.ref.height) { - throw new AttachmentError('Image crop extends beyond the stored master image.', 'INVALID_ATTACHMENT_REF') - } - return crop } function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): string { @@ -104,7 +86,6 @@ function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): str masterAttachmentId: master.attachmentId, routePixelBudget: policy.maxPixels, encodedByteBudget: policy.maxBytes, - crop: policy.crop ?? null, encoding: { png: { compressionLevel: 9, palette: 'opaque-only' }, webpQualities: REQUEST_IMAGE_QUALITIES, @@ -118,7 +99,7 @@ function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): str /** * Complete deterministic identity for one master and route-owned request policy. * @param master - provider-independent durable master reference. - * @param policy - route-owned pixel, byte, and crop policy. + * @param policy - route-owned pixel and byte policy. * @returns branded digest over every request transform input. */ export function requestImageVariantId( @@ -128,20 +109,13 @@ export function requestImageVariantId( return ImageVariantId(`sha256:${digest(descriptor(master, policy))}`) } -function pipeline(master: StoredImageAttachment, crop: MasterImageCrop | undefined, width: number, height: number): Sharp { - return sourcePipeline(master, crop) +function pipeline(master: StoredImageAttachment, width: number, height: number): Sharp { + return sourcePipeline(master) .resize({ width, height, fit: 'inside', withoutEnlargement: true }) } -function sourcePipeline(master: StoredImageAttachment, crop: MasterImageCrop | undefined): Sharp { - let image = sharp(master.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') - if (crop !== undefined) image = image.extract({ - left: crop.x, - top: crop.y, - width: crop.width, - height: crop.height, - }) - return image +function sourcePipeline(master: StoredImageAttachment): Sharp { + return sharp(master.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') } async function encoded( @@ -161,13 +135,12 @@ async function encoded( function encodingAttempts( master: StoredImageAttachment, - crop: MasterImageCrop | undefined, width: number, height: number, hasAlpha: boolean, lowColour: boolean, ): Array<() => Promise> { - const prepared = pipeline(master, crop, width, height) + const prepared = pipeline(master, width, height) const webp = REQUEST_IMAGE_QUALITIES.map(quality => ( () => encoded(prepared.clone(), 'image/webp', quality) )) @@ -183,12 +156,8 @@ async function createRequestImage( policy: ImageRequestPolicy, hasAlpha: boolean, ): Promise { - const crop = checkedCrop(master, policy.crop) - const sourceWidth = crop?.width ?? master.ref.width - const sourceHeight = crop?.height ?? master.ref.height - let dimensions = requestImageDimensions(sourceWidth, sourceHeight, policy.maxPixels) - if (crop === undefined - && dimensions.width === master.ref.width + let dimensions = requestImageDimensions(master.ref.width, master.ref.height, policy.maxPixels) + if (dimensions.width === master.ref.width && dimensions.height === master.ref.height && master.data.byteLength <= policy.maxBytes) { return { @@ -198,10 +167,10 @@ async function createRequestImage( height: master.ref.height, } } - const lowColour = await hasLowColourCount(sourcePipeline(master, crop)) + const lowColour = await hasLowColourCount(sourcePipeline(master)) for (;;) { const encodedVersion = await encodeFirstWithinLimit( - encodingAttempts(master, crop, dimensions.width, dimensions.height, hasAlpha, lowColour), + encodingAttempts(master, dimensions.width, dimensions.height, hasAlpha, lowColour), policy.maxBytes, ) if (!isExhaustedEncoding(encodedVersion)) return encodedVersion @@ -229,8 +198,7 @@ async function readCached( try { const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) - const crop = policy.crop - const maximum = requestImageDimensions(crop?.width ?? master.ref.width, crop?.height ?? master.ref.height, policy.maxPixels) + const maximum = requestImageDimensions(master.ref.width, master.ref.height, policy.maxPixels) if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width > maximum.width || detected.height > maximum.height || detected.hasAlpha !== expectedAlpha) return undefined @@ -285,7 +253,6 @@ export async function readRequestImageFile( ): Promise { signal?.throwIfAborted() validatePolicy(policy) - checkedCrop(master, policy.crop) const source = await probeImage(master.data) const variantId = requestImageVariantId(master.ref, policy) const hash = String(variantId).slice('sha256:'.length) @@ -308,42 +275,5 @@ export async function readRequestImageFile( depth: 'uchar', space: 'srgb', hasAlpha: version.hasAlpha, - ...policy.crop === undefined ? {} : { crop: policy.crop }, - } -} - -/** - * Map a preview-coordinate rectangle to the oriented stored master. - * @param masterWidth - stored master width. - * @param masterHeight - stored master height. - * @param crop - rectangle measured on the model-visible preview. - * @returns covering integer rectangle in master coordinates. - */ -export function previewCropToMaster( - masterWidth: number, - masterHeight: number, - crop: PreviewImageCrop, -): MasterImageCrop { - checkedInteger(masterWidth, 'Master image width') - checkedInteger(masterHeight, 'Master image height') - checkedInteger(crop.previewWidth, 'Preview width') - checkedInteger(crop.previewHeight, 'Preview height') - if (!Number.isSafeInteger(crop.x) || crop.x < 0 || !Number.isSafeInteger(crop.y) || crop.y < 0) { - throw new AttachmentError('Preview crop origin must use non-negative integer pixels.', 'INVALID_ATTACHMENT_REF') - } - checkedInteger(crop.width, 'Preview crop width') - checkedInteger(crop.height, 'Preview crop height') - if (crop.x + crop.width > crop.previewWidth || crop.y + crop.height > crop.previewHeight) { - throw new AttachmentError('Preview crop extends beyond the image shown to the model.', 'INVALID_ATTACHMENT_REF') - } - const x = Math.floor(crop.x * masterWidth / crop.previewWidth) - const y = Math.floor(crop.y * masterHeight / crop.previewHeight) - const right = Math.ceil((crop.x + crop.width) * masterWidth / crop.previewWidth) - const bottom = Math.ceil((crop.y + crop.height) * masterHeight / crop.previewHeight) - return { - x, - y, - width: Math.max(1, Math.min(masterWidth, right) - x), - height: Math.max(1, Math.min(masterHeight, bottom) - y), } } diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 726837a242..c38e8ce137 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import sharp from 'sharp' import { afterEach, describe, expect, it, vi } from 'vitest' import { CompressionLimiter } from '../src/compression-limiter.ts' -import LocalAttachmentStore, { previewCropToMaster, requestImageDimensions } from '../src/index.ts' +import LocalAttachmentStore, { requestImageDimensions } from '../src/index.ts' const homes: string[] = [] @@ -51,23 +51,6 @@ describe('request image dimensions', () => { expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 }) }) - it('rejects invalid preview dimensions, origins, sizes, and bounds', () => { - expect(() => previewCropToMaster(0, 10, { - previewWidth: 10, previewHeight: 10, x: 0, y: 0, width: 1, height: 1, - })).toThrow('Master image width must be a positive integer') - expect(() => previewCropToMaster(10, 10, { - previewWidth: 0, previewHeight: 10, x: 0, y: 0, width: 1, height: 1, - })).toThrow('Preview width must be a positive integer') - expect(() => previewCropToMaster(10, 10, { - previewWidth: 10, previewHeight: 10, x: -1, y: 0, width: 1, height: 1, - })).toThrow('Preview crop origin must use non-negative integer pixels') - expect(() => previewCropToMaster(10, 10, { - previewWidth: 10, previewHeight: 10, x: 0, y: 0, width: 0, height: 1, - })).toThrow('Preview crop width must be a positive integer') - expect(() => previewCropToMaster(10, 10, { - previewWidth: 10, previewHeight: 10, x: 9, y: 0, width: 2, height: 1, - })).toThrow('Preview crop extends beyond the image shown to the model') - }) }) describe('local request-image cache', () => { @@ -85,7 +68,7 @@ describe('local request-image cache', () => { expect(batch.map(value => value.master.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) }) - it('rejects invalid request policies and master crop bounds', async () => { + it('rejects invalid request policies', async () => { const attachments = await store() const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref @@ -93,15 +76,6 @@ describe('local request-image cache', () => { .rejects.toThrow('Image request maxPixels must be a positive integer') await expect(attachments.readImageRequest(master, { maxPixels: 100, maxBytes: 0 })) .rejects.toThrow('Image request maxBytes must be a positive integer') - await expect(attachments.readImageRequest(master, { - maxPixels: 100, maxBytes: 100, crop: { x: -1, y: 0, width: 1, height: 1 }, - })).rejects.toThrow('Image crop origin must use non-negative integer pixels') - await expect(attachments.readImageRequest(master, { - maxPixels: 100, maxBytes: 100, crop: { x: 0, y: 0, width: 0, height: 1 }, - })).rejects.toThrow('Image crop width must be a positive integer') - await expect(attachments.readImageRequest(master, { - maxPixels: 100, maxBytes: 100, crop: { x: 7, y: 0, width: 2, height: 1 }, - })).rejects.toThrow('Image crop extends beyond the stored master image') }) it('refuses a one-pixel request that cannot meet the encoded-byte budget', async () => { @@ -178,51 +152,6 @@ describe('local request-image cache', () => { expect(low.width * low.height).toBeLessThanOrEqual(512 * 512 + low.width) }) - it('maps preview coordinates to the 2048px master and crops the master instead of the preview', async () => { - const attachments = await store() - const pixels = Buffer.alloc(2048 * 1024 * 3) - for (let y = 0; y < 1024; y += 1) { - for (let x = 0; x < 2048; x += 1) { - const offset = (y * 2048 + x) * 3 - pixels[offset] = x < 1024 ? 255 : 0 - pixels[offset + 1] = x < 1024 ? 0 : 255 - pixels[offset + 2] = 0 - } - } - const source = new Uint8Array(await sharp(pixels, { raw: { width: 2048, height: 1024, channels: 3 } }).png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png', name: 'halves.png' })).ref - const preview = await attachments.readImageRequest(master, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) - const previewCrop = { - previewWidth: preview.width, - previewHeight: preview.height, - x: Math.floor(preview.width / 2), - y: 0, - width: preview.width - Math.floor(preview.width / 2), - height: preview.height, - } - const mapped = previewCropToMaster(master.width, master.height, previewCrop) - - const cropped = await attachments.cropImage(master, previewCrop) - const stored = await attachments.readImage(cropped.ref) - const pixel = await sharp(stored.data).resize(1, 1).removeAlpha().raw().toBuffer() - - expect(mapped).toEqual({ x: 1024, y: 0, width: 1024, height: 1024 }) - expect(cropped.ref.width).toBe(mapped.width) - expect(cropped.ref.height).toBe(mapped.height) - expect(pixel[1]).toBeGreaterThan(pixel[0] ?? 0) - }) - - it('names a crop from an unnamed attachment id', async () => { - const attachments = await store() - const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref - - const cropped = await attachments.cropImage(master, { - previewWidth: 8, previewHeight: 4, x: 0, y: 0, width: 4, height: 4, - }) - - expect(cropped.ref.name).toMatch(/^sha256:[0-9a-f]{8}-crop\.(?:png|webp|jpg)$/u) - }) - it('classifies opaque PNG pixels and preserves alpha while enforcing the request budget', async () => { const attachments = await store() const side = 256 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 221699165d..bbccf584c6 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.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 packages/attachment/attachment/README.md -README.md: c4925addf079cdd65defb733e6bc40f91ed6384f -README.zh.md: 5623e0944c6f67e2cdaa90076d794cd617c46d5f +README.md: 66ce5f308cfa1ce6a028dbd248ceef1fdcc31a7c +README.zh.md: 4470956987330a451e3717d419a111def98dd6cb diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index c4925addf0..66ce5f308c 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,13 +4,13 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent master image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every validated master once before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: the returned `ref` describes the stored master while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and orientation-applied dimensions. `readImage` verifies that master against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the master id, transform version, pixel and byte budgets, crop, and encoder settings; `readImageRequests` preserves ordered results while implementations apply their own bounded concurrency. `cropImage` maps preview coordinates to the stored master and persists the result as a new attachment. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every validated master once before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: the returned `ref` describes the stored master while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and orientation-applied dimensions. `readImage` verifies that master against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the master id, transform version, pixel and byte budgets, and encoder settings; `readImageRequests` preserves ordered results while implementations apply their own bounded concurrency. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id, actual preview dimensions, and the `read_image_region` coordinate system. +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. #### KV Cache effect diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 5623e0944c..4470956987 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,13 +4,13 @@ 持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的图片主版本,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前为全部成员各准备一次经过验证的主版本,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:返回的 `ref` 描述实际存储的主版本,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和应用方向后的尺寸。`readImage` 根据已记录的元数据校验该主版本。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖主版本 ID、变换策略版本、像素和字节预算、裁剪区域及编码参数;`readImageRequests` 保持结果顺序,并由实现施加自己的有界并发。`cropImage` 把预览坐标映射到存储的主版本,并把结果保存为新附件。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前为全部成员各准备一次经过验证的主版本,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:返回的 `ref` 描述实际存储的主版本,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和应用方向后的尺寸。`readImage` 根据已记录的元数据校验该主版本。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖主版本 ID、变换策略版本、像素和字节预算及编码参数;`readImageRequests` 保持结果顺序,并由实现施加自己的有界并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID、实际预览尺寸和 `read_image_region` 使用的坐标系。 +该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。 #### KV 缓存影响 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 705346d4cc..85401fad23 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -6,7 +6,6 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, - PreviewImageCrop, RequestImageAttachment, SaveImageAttachment, SavedImageAttachment, @@ -24,8 +23,6 @@ export type { ImageAttachmentRef, ImageRequestPolicy, ImageMediaType, - MasterImageCrop, - PreviewImageCrop, RequestImageAttachment, SaveImageAttachment, SavedImageAttachment, @@ -153,26 +150,6 @@ export abstract class AttachmentStore extends Service { return versions } - /** - * Crop the stored master by coordinates measured on a model request preview and persist the result. - * @param ref - session-authorized master attachment. - * @param crop - preview dimensions and preview-coordinate rectangle. - * @param signal - optional cancellation. - * @returns a new durable attachment reference suitable for a logged tool result. - */ - cropImage( - ref: ImageAttachmentRef, - crop: PreviewImageCrop, - signal?: AbortSignal, - ): Promise { - signal?.throwIfAborted() - void ref - void crop - return Promise.reject(new AttachmentError( - 'The mounted attachment provider cannot crop stored images.', - 'ATTACHMENT_PROJECTION_UNSUPPORTED', - )) - } } export default AttachmentStore diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 1d83cf1afa..04f7362d38 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -63,27 +63,17 @@ export interface StoredImageAttachment { data: Uint8Array } -/** Pixel rectangle in the oriented 2048px master-version coordinate system. */ -export interface MasterImageCrop { - x: number - y: number - width: number - height: number -} - /** Deterministic request-image policy selected by one exact model route. */ export interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ maxPixels: number /** Encoded-byte cap before base64 expansion or Files API upload. */ maxBytes: number - /** Optional master-coordinate crop applied before pixel-budget scaling. */ - crop?: MasterImageCrop } /** Cached request version derived from one provider-independent master attachment. */ export interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, crop, and fixed encoder parameters. */ + /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ variantId: ImageVariantId /** Durable master reference from which this request version was derived. */ master: ImageAttachmentRef @@ -99,18 +89,6 @@ export interface RequestImageAttachment { space: 'srgb' /** Whether the encoded request version retains an alpha channel. */ hasAlpha: boolean - /** Applied master-coordinate crop, when present. */ - crop?: MasterImageCrop -} - -/** Crop coordinates measured by a model on the request preview it received. */ -export interface PreviewImageCrop { - previewWidth: number - previewHeight: number - x: number - y: number - width: number - height: number } /** Intrinsic facts of the submitted source raster, before master-version preparation. */ diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 25afbcd420..589a4322d9 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -151,22 +151,15 @@ describe('AttachmentStore.readImageRequests', () => { expect(versions.map(version => version.master.name)).toEqual(['1.png', '2.png']) }) - it('reports unsupported request projection and crop operations, preserving cancellation', async () => { + it('reports unsupported request projection while preserving cancellation', async () => { const store = new UnsupportedProjectionStore(new Context()) const ref = (await new RecordingStore(new Context()).saveImage(image(1))).ref await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) - await expect(store.cropImage(ref, { - previewWidth: 1, previewHeight: 1, x: 0, y: 0, width: 1, height: 1, - })).rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) - const controller = new AbortController() const reason = new Error('cancel unsupported projection') controller.abort(reason) expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) - expect(() => store.cropImage(ref, { - previewWidth: 1, previewHeight: 1, x: 0, y: 0, width: 1, height: 1, - }, controller.signal)).toThrow(reason) }) }) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 50cbd49d57..ce2007c34b 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -31,7 +31,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_agents', 'lsp', 'pwsh', 'pwsh', 'ralph', - 'read', 'read_image', 'read_image_region', 'report', 'run_code', 'schedule_create', 'schedule_delete', + 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', 'str_replace_editor', 'subagent', 'team_task_create', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1602c351b5..0dbe8f0535 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -467,12 +467,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'refs', description: 'durable provider-independent master references in request order.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget shared by the batch.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request versions in the same order as `refs`.', }, - { - signature: 'cropImage( ref: ImageAttachmentRef, crop: PreviewImageCrop, signal?: AbortSignal, ): Promise', - description: 'Crop the stored master by coordinates measured on a model request preview and persist the result.', - parameters: [{ name: 'ref', description: 'session-authorized master attachment.' }, { name: 'crop', description: 'preview dimensions and preview-coordinate rectangle.' }, { name: 'signal', description: 'optional cancellation.' }], - returns: 'a new durable attachment reference suitable for a logged tool result.', - }, ], }, { @@ -3480,7 +3474,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageRequestPolicy', - declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n crop?: MasterImageCrop;\n}', + declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n}', }, { name: 'ImageVariantId', @@ -3710,10 +3704,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ManualCompactAgentContext', declaration: 'export interface ManualCompactAgentContext extends CompactionAgentContext {\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n}', }, - { - name: 'MasterImageCrop', - declaration: 'export interface MasterImageCrop {\n x: number;\n y: number;\n width: number;\n height: number;\n}', - }, { name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', @@ -3870,10 +3860,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n} | {\n kind: \'ask\';\n reason?: string;\n};', }, - { - name: 'PreviewImageCrop', - declaration: 'export interface PreviewImageCrop {\n previewWidth: number;\n previewHeight: number;\n x: number;\n y: number;\n width: number;\n height: number;\n}', - }, { name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', @@ -3956,7 +3942,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestImageAttachment', - declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n master: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n crop?: MasterImageCrop;\n}', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n master: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', }, { name: 'RequestRunOutcome', diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 47084a3435..6c590d54b4 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.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 packages/fs/tool-fs/README.md -README.md: 94af10c501bcb86465d685f1f20c7d42f3b9d117 -README.zh.md: 4b8e826db3ae15b825d2f888e7d37fc3cafd1b23 +README.md: ab01840f122d6e0df2782b86840432914b27ebd0 +README.zh.md: ef738a3715b6db45d386d56ba2a776960dd341c1 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 94af10c501..ab01840f12 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **model-facing filesystem tools** — `read`, `read_image`, `read_image_region`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. +The **model-facing filesystem tools** — `read`, `read_image`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. @@ -14,7 +14,7 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-fs-observation-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. -`read_image` and `read_image_region` register only while a durable `ctx.attachments` service is mounted. Execution additionally requires the exact routed model to declare `image` input (resolved through `ctx.llm.resolveModelInfo` from the session's latest request header, falling back to agent options). `read_image_region` accepts only a complete attachment id already referenced by the calling session, so it can crop a user upload without a filesystem path but cannot cross session scope. +`read_image` registers only while a durable `ctx.attachments` service is mounted. Execution additionally requires the exact routed model to declare `image` input, resolved through `ctx.llm.resolveModelInfo` from the session's latest request header and then from agent options. ## Config @@ -32,14 +32,13 @@ All keys are optional; the defaults are the shipped read caps. | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | -| `read_image` | `file_path` | Reads a PNG/JPEG/WebP/GIF file through the bounded byte seam, persists it through `ctx.attachments.saveImage`, and returns an image block beside a small metadata envelope. It succeeds only when the exact routed model declares image input. | -| `read_image_region` | `attachment_id`, `preview_width`, `preview_height`, `x`, `y`, `width`, `height` | Resolves a session-authorized image, maps the preview-coordinate rectangle to its stored master, persists the crop, and returns the new image block. | +| `read_image` | `file_path` | Reads a PNG/JPEG/WebP/GIF file through the bounded byte seam, persists it through `ctx.attachments.saveImage`, and returns an image block beside a small metadata envelope. Harness validates and downscales large supported images before the next model request, so the model can read the source directly without first creating a thumbnail. It succeeds only when the exact routed model declares image input. | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -Structured successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`, `read_image_region` → `{ sourceAttachmentId, preview, crop, image }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. The image source fields appear only when master preparation downscaled the submitted raster. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`; execution-local structured values are not added to `tool/result`, while image renderers emit the durable image blocks that the result logs. +Structured successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. The image source fields appear only when master preparation downscaled the submitted raster. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`; execution-local structured values are not added to `tool/result`, while image renderers emit the durable image blocks that the result logs. ## The tool is the executor; policy is an event gate @@ -47,7 +46,6 @@ The tools do **not** inject a policy service or inspect any cache. Each tool res - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) - **read_image** — validates the argument, extension, attachment availability, deployment media types, and the image-capable route before any I/O; then one `ctx.fs.stat` (recording an `absent` observation for a missing target, like `read`), a bounded `ctx.fs.readBytes` capped at the smaller of `imageLimits.maxImageBytes` and `imageLimits.maxMessageImageBytes` (the result is one message carrying one image), `attachments.saveImage` (content-addressed, so the image block references a durably committed object by the time `tool/result` is appended), and finally `fs/observed`. (1 stat.) -- **read_image_region** — resolves the full attachment id only from current session messages, validates integer preview coordinates, maps the rectangle to the stored master through `attachments.cropImage`, and returns the persisted crop as an image block. It performs no filesystem-path operation and emits no `fs/observed` event. - **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) - **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) @@ -101,7 +99,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr #### What the model sees -The model sees the generated [`read`, `read_image`, `read_image_region`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. The image tools appear only while a durable attachment store is mounted; their schemas are route-independent, and the strict gate refuses at execution. Scoped tool restrictions can remove any definition for one agent. +The model sees the generated [`read`, `read_image`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. The image tool appears only while a durable attachment store is mounted; its schema is route-independent, and the strict gate refuses at execution. Scoped tool restrictions can remove any definition for one agent. #### Token effect @@ -129,7 +127,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, master dimensions, and byte size, followed by the image itself as a native image block. A successful `read_image_region` returns an `image-region` envelope naming the source attachment, supplied preview dimensions and rectangle, and result dimensions, followed by the crop as a native image block. The result is logged with its new durable reference before the next model request. Request adapters derive previews from the master, so later region reads never crop an already reduced preview. +A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, master dimensions, and byte size, followed by the image itself as a native image block. The result is logged with its durable reference before the next model request. #### Token effect @@ -157,7 +155,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`. A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Region reads reject empty or out-of-scope attachment ids and invalid preview rectangles before storage mutation. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`. A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect @@ -173,4 +171,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **`read` handles UTF-8 text files only** — images use the separate extension-routed `read_image` tool; PDF, audio, and video remain deferred. A directory target is `FS_NOT_REGULAR_FILE`. - **Extension-declared media type** — the extension selects the declared type and the attachment store's magic-byte validation stays authoritative; a correctly formatted image under a wrong extension is refused with the rename remedy rather than sniffed. - **No inline image preview on the tool-result card** — UI surfaces render the image result generically (the durable reference, not pixels); inline rendering is deferred to the UI packages. +- **No attachment-region tool** — an agent may crop an image through other available tools when it has a filesystem path. A pasted or dragged image without a path cannot be re-read at a higher resolution. - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 4b8e826db3..ef738a3715 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统工具**(`read`、`read_image`、`read_image_region`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取、写入和编辑。新鲜度与观察策略由独立插件([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 +**面向模型的文件系统工具**(`read`、`read_image`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取、写入和编辑。新鲜度与观察策略由独立插件([`@deepseek-ai/dsh-fs-observation-policy`](../fs-observation-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. @@ -14,7 +14,7 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-fs-observation-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供写入/编辑前读取行为。 -`read_image` 和 `read_image_region` 只在持久 `ctx.attachments` 服务已挂载时注册。执行时还要求确切路由的模型声明 `image` 输入,通过 `ctx.llm.resolveModelInfo` 从会话最新请求 header 解析,缺失时回退到 agent 选项。`read_image_region` 只接受调用会话已经引用的完整附件 ID,因此可以裁剪没有文件路径的用户上传图片,但不能越过会话范围。 +`read_image` 只在持久 `ctx.attachments` 服务已挂载时注册。执行时还要求确切路由的模型声明 `image` 输入,通过 `ctx.llm.resolveModelInfo` 依次从会话最新请求 header 和 agent 选项解析。 ## 配置 @@ -32,14 +32,13 @@ await ctx.plugin(ToolFs) // this package — re | 工具 | 参数 | 行为 | |---|---|---| | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | -| `read_image` | `file_path` | 通过有界字节 seam 读取 PNG/JPEG/WebP/GIF 文件,经 `ctx.attachments.saveImage` 持久保存,并在小型元数据信封旁返回图像块。只有确切路由的模型声明图像输入时才会成功。 | -| `read_image_region` | `attachment_id`、`preview_width`、`preview_height`、`x`、`y`、`width`、`height` | 解析会话有权访问的图片,把预览坐标矩形映射到存储主版本,持久保存裁剪结果并返回新图片块。 | +| `read_image` | `file_path` | 通过有界字节 seam 读取 PNG/JPEG/WebP/GIF 文件,经 `ctx.attachments.saveImage` 持久保存,并在小型元数据信封旁返回图像块。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此模型可以直接读取源文件,无需先创建缩略图。只有确切路由的模型声明图像输入时才会成功。 | | `write` | `file_path`、`content` | 创建文件或完整替换文件。有策略插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | | `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有策略插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -结构化成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`,`read_image_region` → `{ sourceAttachmentId, preview, crop, image }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。图片 source 字段只在主版本准备缩小了提交光栅时出现。原生渲染器会保留下方带行号的读取结果和变更确认。`write` 和 `edit` 从这些值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;仅用于执行的结构化值不会添加到 `tool/result`,图片渲染器则会发出由结果记录的持久图片块。 +结构化成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。图片 source 字段只在主版本准备缩小了提交光栅时出现。原生渲染器会保留下方带行号的读取结果和变更确认。`write` 和 `edit` 从这些值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;仅用于执行的结构化值不会添加到 `tool/result`,图片渲染器则会发出由结果记录的持久图片块。 ## 工具就是执行器;策略是事件门禁 @@ -47,7 +46,6 @@ await ctx.plugin(ToolFs) // this package — re - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) - **read_image**:在任何 I/O 之前校验参数、扩展名、附件可用性、部署接受的媒体类型和图像路由;随后一次 `ctx.fs.stat`(目标缺失时与 `read` 一样记录 `absent` 观察)、以 `imageLimits.maxImageBytes` 与 `imageLimits.maxMessageImageBytes` 中较小者为上限的有界 `ctx.fs.readBytes`(结果是携带一张图像的一条消息)、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。(1 次 stat。) -- **read_image_region**:只从当前会话消息解析完整附件 ID,校验整数预览坐标,通过 `attachments.cropImage` 把矩形映射到存储主版本,并把持久裁剪结果作为图片块返回。它不执行文件系统路径操作,也不发出 `fs/observed` 事件。 - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) - **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -101,7 +99,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -模型会看到已生成的 [`read`、`read_image`、`read_image_region`、`write` 和 `edit` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-fs),参数使用 snake_case。图片工具只在持久附件存储已挂载时出现;schema 本身与路由无关,严格门禁在执行时拒绝。作用域工具限制可以为某个 agent 移除任一定义。 +模型会看到已生成的 [`read`、`read_image`、`write` 和 `edit` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-fs),参数使用 snake_case。图片工具只在持久附件存储已挂载时出现;schema 本身与路由无关,严格门禁在执行时拒绝。作用域工具限制可以为某个 agent 移除任一定义。 #### Token 影响 @@ -129,7 +127,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -成功的 `read_image` 返回 ``、`image` 和写明媒体类型、主版本尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。成功的 `read_image_region` 返回 `image-region` 信封,写明源附件、提交的预览尺寸和矩形及结果尺寸,随后是作为原生图像块的裁剪结果。新持久引用会随结果写入会话日志,然后才进入下一次模型请求。请求适配器从主版本派生预览,因此之后的局部读取不会从已经缩小的预览继续裁剪。 +成功的 `read_image` 返回 ``、`image` 和写明媒体类型、主版本尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。结果会随持久引用写入会话日志,然后才进入下一次模型请求。 #### Token 影响 @@ -157,7 +155,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。局部读取会在改变存储前拒绝空白或超出会话范围的附件 ID 以及无效预览矩形。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,不会重复陈旧恢复指令;write 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 @@ -173,4 +171,5 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces - **`read` 只处理 UTF-8 文本文件**:图像使用独立的、按扩展名路由的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。 - **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。 - **工具结果卡片没有内嵌图像预览**:UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理。 +- **没有附件局部读取工具**:图片具有文件路径时,agent 可以用其他可用工具裁剪。粘贴或拖入但没有路径的图片无法按更高分辨率重新读取。 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.zh.md#no-timeouts-on-file-io))。 diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index a900c0c720..bbf49d568c 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -1,7 +1,5 @@ /** - * The model-facing image tools: `read_image` commits a PNG/JPEG/WebP/GIF file, - * while `read_image_region` crops a session-authorized durable attachment by - * coordinates measured on the exact preview shown to the model. + * The model-facing `read_image` tool commits a PNG/JPEG/WebP/GIF file. * * The route gate is deliberately stricter than the host upload preflight. An * image-reading tool is useful only when the exact calling route can inspect @@ -13,7 +11,7 @@ import { basename, extname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageMediaType, PreviewImageCrop } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -62,14 +60,6 @@ export interface ImageReadValue { } } -/** Structured result of cropping a session-authorized image attachment. */ -export interface ImageRegionReadValue { - sourceAttachmentId: string - preview: { width: number; height: number } - crop: { x: number; y: number; width: number; height: number } - image: ImageReadValue['image'] -} - /** * Map a model-supplied path to its declared image media type by extension. * @param filePath - the raw `file_path` argument (not yet resolved). @@ -120,55 +110,6 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme } } -function findImageRef( - content: readonly ContentBlock[], - attachmentId: string, -): ImageAttachmentRef | undefined { - for (const block of content) { - if (block.type === 'image' && block.attachment.attachmentId === attachmentId) return block.attachment - if (block.type === 'tool-result') { - const nested = findImageRef(block.content, attachmentId) - if (nested !== undefined) return nested - } - } - return undefined -} - -function sessionImageRef(exec: ToolExecution, attachmentId: string): ImageAttachmentRef { - const session = exec.agent?.session - if (session === undefined) { - throw new Error('read_image_region requires an active agent session') - } - for (const message of session.deriveMessages()) { - const ref = findImageRef(message.content, attachmentId) - if (ref !== undefined) return ref - } - throw new Error(`attachment "${attachmentId}" is not referenced by the current session`) -} - -function positiveInteger(value: number, name: string): number { - if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`) - return value -} - -function nonNegativeInteger(value: number, name: string): number { - if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`) - return value -} - -function regionReadContent(value: ImageRegionReadValue): ContentBlock[] { - return [ - { - type: 'text', - text: `${value.sourceAttachmentId}\nimage-region\n\n` - + `preview ${value.preview.width}x${value.preview.height} px; crop ` - + `x=${value.crop.x}, y=${value.crop.y}, width=${value.crop.width}, height=${value.crop.height}; ` - + `result ${value.image.width}x${value.image.height} px\n`, - }, - { type: 'image', attachment: imageRefFromValue(value.image) }, - ] -} - /** * Format an image read as the model-facing envelope beside its image block. * A downscaled read names the on-disk dimensions and the multiplier that maps @@ -220,7 +161,9 @@ function imageReadContent(value: ImageReadValue): ContentBlock[] { export function applyReadImageTool(ctx: Context): void { ctx.tools.register(defineTool({ name: 'read_image', - description: 'Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.', + description: 'Read a PNG/JPEG/WebP/GIF file and return the image itself. ' + + 'Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. ' + + 'Independent files may be read concurrently in small batches. Requires the current model to accept image input.', parameters: { file_path: { type: 'string', required: true, description: 'Path to the image file, resolved by the filesystem backend.' }, }, @@ -333,87 +276,4 @@ export function applyReadImageTool(ctx: Context): void { } }, })) - - ctx.tools.register(defineTool({ - name: 'read_image_region', - description: 'Crop a region from an image attachment already visible in this session. Coordinates use the preview dimensions supplied beside that image.', - parameters: { - attachment_id: { type: 'string', required: true, description: 'Complete attachment id shown beside the image.' }, - preview_width: { type: 'integer', required: true, description: 'Width of the preview shown to the model.' }, - preview_height: { type: 'integer', required: true, description: 'Height of the preview shown to the model.' }, - x: { type: 'integer', required: true, description: 'Left edge in preview pixels.' }, - y: { type: 'integer', required: true, description: 'Top edge in preview pixels.' }, - width: { type: 'integer', required: true, description: 'Crop width in preview pixels.' }, - height: { type: 'integer', required: true, description: 'Crop height in preview pixels.' }, - }, - output: { - schema: { - type: 'object', - additionalProperties: false, - properties: { - sourceAttachmentId: { type: 'string', required: true }, - preview: { - type: 'object', - additionalProperties: false, - required: true, - properties: { - width: { type: 'integer', required: true }, - height: { type: 'integer', required: true }, - }, - }, - crop: { - type: 'object', - additionalProperties: false, - required: true, - properties: { - x: { type: 'integer', required: true }, - y: { type: 'integer', required: true }, - width: { type: 'integer', required: true }, - height: { type: 'integer', required: true }, - }, - }, - image: IMAGE_VALUE_SCHEMA, - }, - }, - render: (_args, value) => regionReadContent(value), - }, - isConcurrencySafe: () => true, - async execute(args, exec) { - const attachmentId = args.attachment_id.trim() - if (attachmentId.length === 0) throw new Error('attachment_id must be a non-empty string') - const ref = sessionImageRef(exec, attachmentId) - await assertImageCapableRoute(ctx, exec, attachmentId) - const crop: PreviewImageCrop = { - previewWidth: positiveInteger(args.preview_width, 'preview_width'), - previewHeight: positiveInteger(args.preview_height, 'preview_height'), - x: nonNegativeInteger(args.x, 'x'), - y: nonNegativeInteger(args.y, 'y'), - width: positiveInteger(args.width, 'width'), - height: positiveInteger(args.height, 'height'), - } - const saved = await ctx.attachments.cropImage(ref, crop, exec.signal) - return { - sourceAttachmentId: ref.attachmentId, - preview: { width: crop.previewWidth, height: crop.previewHeight }, - crop: { x: crop.x, y: crop.y, width: crop.width, height: crop.height }, - image: { - attachmentId: saved.ref.attachmentId, - mediaType: saved.ref.mediaType, - bytes: saved.ref.bytes, - width: saved.ref.width, - height: saved.ref.height, - ...saved.ref.name === undefined ? {} : { name: saved.ref.name }, - ...saved.ref.sourceWidth === undefined ? {} : { sourceWidth: saved.ref.sourceWidth }, - ...saved.ref.sourceHeight === undefined ? {} : { sourceHeight: saved.ref.sourceHeight }, - }, - } - }, - presentCall(args): GenericCallView { - return { - card: 'generic', - title: `Read image region ${args.attachment_id}`, - kind: 'read', - } - }, - })) } diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 03616911b5..16e07d93a8 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import { CallId, createUserMessage, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, Message, StreamChunk } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' @@ -175,214 +175,6 @@ describe('imageRefFromValue', () => { }) }) -describe('read_image_region', () => { - it('crops a session-visible attachment and returns a new logged image reference', async () => { - const ctx = await setup() - const attachments = ctx.attachments - const source = await attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png', name: 'grid.png' }) - const history = [createUserMessage({ - content: [{ type: 'image', attachment: source.ref }], - source: { kind: 'plugin', plugin: 'test' }, - })] - - const result = await call(ctx, 'read_image_region', { - attachment_id: source.ref.attachmentId, - preview_width: 3, - preview_height: 3, - x: 1, - y: 0, - width: 2, - height: 2, - }, agentOn('vision-model', 'visual', history)) - - expect(result.isError).toBe(false) - expect(result.content[0]).toMatchObject({ - type: 'text', - text: expect.stringContaining('crop x=1, y=0, width=2, height=2') as string, - }) - expect(result.content[1]).toMatchObject({ - type: 'image', - attachment: { width: 2, height: 2, name: 'grid-crop.png' }, - }) - const cropped = result.content[1] - if (cropped?.type !== 'image') throw new Error('expected cropped image block') - await expect(attachments.readImage(cropped.attachment)).resolves.toMatchObject({ - ref: { attachmentId: cropped.attachment.attachmentId }, - }) - }) - - it('refuses an attachment that is absent from the current session', async () => { - const ctx = await setup() - const result = await call(ctx, 'read_image_region', { - attachment_id: `sha256:${'f'.repeat(64)}`, - preview_width: 800, - preview_height: 800, - x: 0, - y: 0, - width: 100, - height: 100, - }, agentOn('vision-model')) - - expect(result.isError).toBe(true) - expect(text(result)).toContain('not referenced by the current session') - }) - - it('finds images nested in tool results after skipping a non-matching nested result', async () => { - const ctx = await setup() - const source = await ctx.attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png' }) - const history = [createUserMessage({ - content: [ - { type: 'tool-result', toolCallId: CallId('unrelated'), content: [{ type: 'text', text: 'none' }] }, - { type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'image', attachment: source.ref }] }, - ], - source: { kind: 'plugin', plugin: 'test' }, - })] - - const result = await call(ctx, 'read_image_region', { - attachment_id: source.ref.attachmentId, - preview_width: 3, - preview_height: 3, - x: 0, - y: 0, - width: 1, - height: 1, - }, agentOn('vision-model', 'visual', history)) - - expect(result.isError).toBe(false) - }) - - it('continues across an earlier session message without the requested image', async () => { - const ctx = await setup() - const source = await ctx.attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png' }) - const history = [ - createUserMessage({ - content: [{ type: 'text', text: 'before image' }], - source: { kind: 'plugin', plugin: 'test' }, - }), - createUserMessage({ - content: [{ type: 'image', attachment: source.ref }], - source: { kind: 'plugin', plugin: 'test' }, - }), - ] - - const result = await call(ctx, 'read_image_region', { - attachment_id: source.ref.attachmentId, - preview_width: 3, - preview_height: 3, - x: 0, - y: 0, - width: 1, - height: 1, - }, agentOn('vision-model', 'visual', history)) - - expect(result.isError).toBe(false) - }) - - it('rejects a missing session, empty id, and invalid coordinate arguments', async () => { - const ctx = await setup() - const base = { - attachment_id: `sha256:${'f'.repeat(64)}`, - preview_width: 1, - preview_height: 1, - x: 0, - y: 0, - width: 1, - height: 1, - } - const noSession = await call(ctx, 'read_image_region', base) - expect(text(noSession)).toContain('requires an active agent session') - - const empty = await call(ctx, 'read_image_region', { ...base, attachment_id: ' ' }, agentOn('vision-model')) - expect(text(empty)).toContain('attachment_id must be a non-empty string') - - const source = await ctx.attachments.saveImage({ data: PNG_1X1, mediaType: 'image/png' }) - const history = [createUserMessage({ - content: [{ type: 'image', attachment: source.ref }], - source: { kind: 'plugin', plugin: 'test' }, - })] - const agent = agentOn('vision-model', 'visual', history) - for (const [field, value, expected] of [ - ['preview_width', 0, 'preview_width must be a positive integer'], - ['preview_height', 0, 'preview_height must be a positive integer'], - ['x', -1, 'x must be a non-negative integer'], - ['y', -1, 'y must be a non-negative integer'], - ['width', 0, 'width must be a positive integer'], - ['height', 0, 'height must be a positive integer'], - ] as const) { - const result = await call(ctx, 'read_image_region', { - ...base, - attachment_id: source.ref.attachmentId, - [field]: value, - }, agent) - expect(text(result)).toContain(expected) - } - }) - - it('projects optional crop metadata from a provider result', async () => { - class CropMetadataStore extends AttachmentStore { - readonly imageLimits: ImageAttachmentLimits = { - maxImageBytes: 1024, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1024, - maxImagePixels: 100, - maxImageDimension: 100, - mediaTypes: ['image/png'], - } - - validateImage(): Promise { return Promise.resolve() } - saveImage(): Promise { throw new Error('not used') } - readImage(): Promise { throw new Error('not used') } - override cropImage(ref: ImageAttachmentRef): Promise { - return Promise.resolve({ - ref: { ...ref, sourceWidth: 2, sourceHeight: 2 }, - source: { mediaType: ref.mediaType, bytes: ref.bytes, width: 2, height: 2 }, - }) - } - } - const ctx = await setup({ attachments: false }) - await ctx.plugin(CropMetadataStore) - const ref: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', bytes: 1, width: 1, height: 1, - } - const history = [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'plugin', plugin: 'test' }, - })] - - const result = await call(ctx, 'read_image_region', { - attachment_id: ref.attachmentId, - preview_width: 1, - preview_height: 1, - x: 0, - y: 0, - width: 1, - height: 1, - }, agentOn('vision-model', 'visual', history)) - - expect(result.content[1]).toMatchObject({ - type: 'image', - attachment: { sourceWidth: 2, sourceHeight: 2 }, - }) - expect(result.content[1]).not.toHaveProperty('attachment.name') - }) - - it('declares a generic read presentation for image-region calls', async () => { - const ctx = await setup() - - expect(ctx.tools.get('read_image_region')?.presentCall?.({ - attachment_id: 'sha256:abc', - preview_width: 1, - preview_height: 1, - x: 0, - y: 0, - width: 1, - height: 1, - })) - .toEqual({ card: 'generic', title: 'Read image region sha256:abc', kind: 'read' }) - }) -}) - describe('read_image happy path', () => { it('commits the bytes durably and renders the envelope beside an image block', async () => { await writeFile(join(dir, 'red.png'), PNG_1X1) @@ -773,7 +565,7 @@ describe('registration surface', () => { const attachmentsFiber = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) const toolFsFiber = await ctx.plugin(ToolFs) const names = () => ctx.tools.schemas().map(schema => schema.name).sort() - expect(names()).toEqual(['edit', 'read', 'read_image', 'read_image_region', 'write']) + expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) // Disposing only the attachment store tears down the scoped inject fiber: // read_image withdraws while the unconditional tools stay registered. @@ -782,7 +574,7 @@ describe('registration surface', () => { // Remounting the store restores the conditional registration. const remounted = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) - expect(names()).toEqual(['edit', 'read', 'read_image', 'read_image_region', 'write']) + expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) // Disposing the whole plugin withdraws every tool, read_image included. await toolFsFiber.dispose() @@ -801,12 +593,6 @@ describe('registration surface', () => { kind: 'read', locations: [{ path: 'shot.png' }], }) - expect(ctx.tools.executionMode({ - signal: testToolSignal, - callId: CallId('region-parallel'), - name: 'read_image_region', - arguments: { attachment_id: 'sha256:a', preview_width: 1, preview_height: 1, x: 0, y: 0, width: 1, height: 1 }, - })).toEqual({ kind: 'parallel' }) }) }) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 71f7f71308..bea18ff3ac 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: b20d93394055e3e10dfb5a932660b6a510428492 -README.zh.md: 6e8166227d740c0431c17c091d68b5d56aea0dc5 +README.md: bb7f6a520701134cd43ff6223ef4efbf82d02eb4 +README.zh.md: 934c189232711655aa785a7497f5bb6dff1cbb46 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index b20d933940..bb7f6a5207 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -49,11 +49,11 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 master becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. Preview-coordinate arguments are included only when the request exposes `read_image_region`. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 master becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained masters are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. -Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, crop, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. +Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. Concurrent resolution of one scoped `variantId` shares one Files upload with waiter-local cancellation. One quota upload failure first paginates and collects the configured number of oldest `dsh-` files, then deletes that set before one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. @@ -104,7 +104,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config. The vision model receives retained user and tool-result images as Files API references beside stable attachment handles and preview dimensions; an over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config. The vision model receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; an over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 6e8166227d..934c189232 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -49,11 +49,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 主版本会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。只有当前请求公开 `read_image_region` 时才会提供预览坐标参数。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 主版本会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 `maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的主版本。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 -上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算、裁剪区域及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 同一作用域和 `variantId` 的并发解析共享一次 Files 上传,每个等待方可以单独取消。一次上传配额错误会先分页收集配置数量的最旧 `dsh-` 文件,再删除这些文件并重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 @@ -104,7 +104,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置。视觉模型会通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和预览尺寸;超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置。视觉模型会通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 3a01d424ca..30817c738e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -543,7 +543,6 @@ export class DeepSeekAdapter extends LlmAdapter { maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, - cropAvailable: options.tools?.some(tool => tool.name === 'read_image_region') ?? false, }, connection.defaults) const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index b998b23a8b..4ac6280cd4 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,7 +6,7 @@ * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImagePreviewText } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { @@ -47,8 +47,6 @@ export interface ImageSerializationOptions { byteQuantum?: number /** Image-count removal step applied after the request exceeds its count bound. */ countQuantum?: number - /** Whether the active request exposes the region-read tool. */ - cropAvailable?: boolean } /** Durable message and image ordinal used in provider diagnostics. */ @@ -120,11 +118,10 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { function imageHandle( version: RequestImageAttachment, precededByContent: boolean, - cropAvailable: boolean, ): WireTextContentPart { return { type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImagePreviewText(version, cropAvailable)}`, + text: `${precededByContent ? '\n' : ''}${requestImageHandleText(version)}`, } } @@ -143,7 +140,7 @@ async function imageParts( ) } return [ - imageHandle(version, precededByContent, images.cropAvailable === true), + imageHandle(version, precededByContent), { type: 'file', file_id: await images.resolveFileId(version, block, location) }, ] } diff --git a/packages/llm/llm-deepseek/src/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts index 12d433b760..297e1021c1 100644 --- a/packages/llm/llm-deepseek/src/upload-index.ts +++ b/packages/llm/llm-deepseek/src/upload-index.ts @@ -15,7 +15,7 @@ export interface DeepSeekUploadRecord { scope: DeepSeekFileScopeType /** Provider-independent master attachment from which the uploaded request version was derived. */ masterAttachmentId: AttachmentId - /** Complete request transformation identity, including crop and encoder parameters. */ + /** Complete request transformation identity, including route budgets and encoder parameters. */ variantId: ImageVariantIdType fileId: DeepSeekFileIdType bytes: number diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 2663c4cd78..08b3706758 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -176,7 +176,6 @@ describe('DeepSeekAdapter against a mock server', () => { await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', - tools: [{ name: 'read_image_region', description: 'crop', parameters: { type: 'object' } }], messages: [createUserMessage({ content: [ { type: 'text', text: 'describe ' }, @@ -192,7 +191,7 @@ describe('DeepSeekAdapter against a mock server', () => { role: 'user', content: [ { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining('Call read_image_region') as string }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request image 1x1px.`) as string }, { type: 'file', file_id: 'file-api-1' }, ], }], diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 06af757c9a..1b14a0c320 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -60,7 +60,6 @@ function imageOptions( resolveFileId, requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), maxRequestFilesBytes, - cropAvailable: true, } } @@ -353,14 +352,14 @@ describe('image serialization', () => { role: 'user', content: [ { type: 'text', text: 'before' }, - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; preview 1x1px`) as string }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request image 1x1px`) as string }, { type: 'file', file_id: 'file-api-image' }, { type: 'text', text: 'after' }, ], }]) }) - it('gives image-only input a stable handle and preview coordinate system', async () => { + it('gives image-only input a stable handle and request dimensions', async () => { const ref = imageRef() const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', @@ -373,32 +372,12 @@ describe('image serialization', () => { expect(wire.messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: expect.stringContaining('Call read_image_region') as string }, + { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, { type: 'file', file_id: 'file-api-image' }, ], }]) }) - it('does not advertise region reads when the request omits that tool', async () => { - const ref = imageRef() - const images = { ...imageOptions([ref]), cropAvailable: false } - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'plugin', plugin: 'test' }, - })], - }), images) - - expect(wire.messages[0]).toMatchObject({ - role: 'user', - content: [ - { type: 'text', text: `Image ${ref.attachmentId}; preview 1x1px.` }, - { type: 'file', file_id: 'file-api-image' }, - ], - }) - }) - it('rejects an image whose prepared request version is absent', async () => { const ref = imageRef() await expect(serializeMessagesWithImages([createUserMessage({ @@ -528,14 +507,14 @@ describe('image serialization', () => { { role: 'tool', tool_call_id: 'before-system', - content: expect.stringContaining('Call read_image_region') as string, + content: expect.stringContaining('request image 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'system', content: 'system history' }, { role: 'tool', tool_call_id: 'before-assistant', - content: expect.stringContaining('Call read_image_region') as string, + content: expect.stringContaining('request image 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'assistant', content: 'assistant history' }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b951aad76e..038224198d 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.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 packages/llm/llm-pi-ai/README.md -README.md: 044038aa69535ad90c9dc59ad63f05ab68560d28 -README.zh.md: d4b5dff10ea0f3668038cc4d3a6876f52ae273cb +README.md: 8f4d1537d8ccec3e89c0553f877541d11b285f66 +README.zh.md: 354851018de0ea79b82215c3d970266cd2be5763 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 360da6a73b..8f4d1537d8 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent master under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading masters, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual preview dimensions. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent master under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading masters, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. The text includes `read_image_region` preview coordinates only when that tool is present in the request. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded masters are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded masters are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index bf05671ee3..354851018d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的主版本派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取主版本前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际预览尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的主版本派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取主版本前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。只有请求包含 `read_image_region` 时,文本才会提供该工具使用的预览坐标。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的主版本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的主版本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 4c31d2638b..5d2df24d18 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImagePreviewText } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentId, @@ -48,7 +48,6 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { async function userContent( blocks: readonly ContentBlock[], requestImages: ReadonlyMap, - cropAvailable: boolean, ): Promise { const content: (TextContent | ImageContent)[] = [] for (const block of blocks) { @@ -61,7 +60,7 @@ async function userContent( if (version === undefined) { throw new LlmError(`pi-ai request image ${block.attachment.attachmentId} was not prepared`, 'INVALID_REQUEST') } - content.push({ type: 'text', text: requestImagePreviewText(version, cropAvailable) }) + content.push({ type: 'text', text: requestImageHandleText(version) }) content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), @@ -71,7 +70,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(block.content, requestImages, cropAvailable) + const nested = await userContent(block.content, requestImages) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -241,7 +240,6 @@ async function toPiContextWithImages( byteQuantum: 1, byteLength: ref => requestImages.get(ref.attachmentId)?.bytes ?? ref.bytes, }) - const cropAvailable = options.tools?.some(tool => tool.name === 'read_image_region') ?? false const toolNames = new Map() const messages: PiMessage[] = [] @@ -263,7 +261,7 @@ async function toPiContextWithImages( } // user role: text + tool results (each result becomes its own message). const regular = message.content.filter(block => block.type !== 'tool-result') - const content = await userContent(regular, requestImages, cropAvailable) + const content = await userContent(regular, requestImages) const results = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) @@ -271,7 +269,7 @@ async function toPiContextWithImages( messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { - const resultContent = await userContent(result.content, requestImages, cropAvailable) + const resultContent = await userContent(result.content, requestImages) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 8a3f7bd084..da1dcaf28b 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -309,17 +309,6 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) }) - it('advertises region reads only when the request exposes the tool', async () => { - const withoutCrop = await toPiContext(request([user([{ type: 'image', attachment: ref }])]), attachments) - const withCrop = await toPiContext({ - ...request([user([{ type: 'image', attachment: ref }])]), - tools: [{ name: 'read_image_region', description: 'crop', parameters: { type: 'object' } }], - }, attachments) - - expect(JSON.stringify(withoutCrop.messages)).not.toContain('Call read_image_region') - expect(JSON.stringify(withCrop.messages)).toContain('Call read_image_region') - }) - it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const exact = await toPiContext(request([ diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 72e452e005..c30a62dccb 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -19,17 +19,12 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { } /** - * Stable model-facing handle and coordinate description for one exact request preview. + * Stable model-facing handle for one exact request image. * @param version - exact request image shown beside the text. - * @param cropAvailable - whether the active request exposes `read_image_region`. - * @returns attachment handle, preview dimensions, and crop-coordinate guidance. + * @returns attachment handle and request-image dimensions. */ -export function requestImagePreviewText(version: RequestImageAttachment, cropAvailable: boolean): string { - const identity = `Image ${version.master.attachmentId}; preview ${version.width}x${version.height}px.` - return cropAvailable - ? `${identity} Crop coordinates use this preview. Call read_image_region with this attachment_id, ` - + `preview_width=${version.width}, preview_height=${version.height}, x, y, width, and height.` - : identity +export function requestImageHandleText(version: RequestImageAttachment): string { + return `Image ${version.master.attachmentId}; request image ${version.width}x${version.height}px.` } /** diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 558dafca6b..a5ed9feff9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -294,7 +294,6 @@ export const LINK_MAP: Readonly> = { EncodedImageAttachment: 'attachment.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', - PreviewImageCrop: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', SavedImageAttachment: 'attachment.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 48ba2255a5..805316352b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -315,17 +315,17 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-fs', source: 'packages/fs/tool-fs/src/index.ts', requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (image-tool registration)', 'ctx.llm + an image-capable route (image-tool execution)'], - writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image and read_image_region)', 'tool/result'], + writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image)', 'tool/result'], async mount(ctx) { // The tool needs `fs`; the bare provider is sufficient because policy // changes behavior, not schema shape. The catalog seam marker opts into - // both attachments-conditional image schemas without attachment I/O. + // the attachments-conditional image schema without attachment I/O. await ctx.plugin(LocalFileSystem) await ctx.plugin(CatalogAttachmentStore) await ctx.plugin(ToolFs) }, note: - 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tools are not registered without `ctx.attachments`; their schemas are route-independent, and execution refuses unless the exact routed model declares image input.', + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The image tool is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.', }, { pkg: '@deepseek-ai/dsh-tool-fs-search', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 946015d483..a83e2fc8e7 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -930,21 +930,11 @@ "symbol": "StoredImageAttachment", "source": "packages/attachment/attachment/src/types.ts" }, - { - "doc": "docs/subsystems/attachment.md", - "symbol": "MasterImageCrop", - "source": "packages/attachment/attachment/src/types.ts" - }, { "doc": "docs/subsystems/attachment.md", "symbol": "ImageRequestPolicy", "source": "packages/attachment/attachment/src/types.ts" }, - { - "doc": "docs/subsystems/attachment.md", - "symbol": "PreviewImageCrop", - "source": "packages/attachment/attachment/src/types.ts" - }, { "doc": "docs/subsystems/attachment.md", "symbol": "RequestImageAttachment", From 2491e12fd81f0bcd0d8ed18f28878a5742cd1897 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 13:19:50 +0800 Subject: [PATCH 055/248] refactor(attachment): normalize image storage API --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 26 ++-- ...08-20-unified-image-request-pipeline.zh.md | 24 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 12 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 55 ++++--- docs/subsystems/attachment.zh.md | 55 ++++--- .../system-prompt.expected.md | 6 +- packages/acp/acp/tests/dispose.spec.ts | 2 +- packages/acp/acp/tests/harness.ts | 9 +- packages/acp/acp/tests/turns.spec.ts | 6 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 8 +- .../attachment/attachment-local/README.zh.md | 8 +- .../attachment-local/src/encoding.ts | 2 +- .../attachment/attachment-local/src/index.ts | 61 ++++---- .../src/{canonical.ts => normalization.ts} | 63 ++++---- .../attachment-local/src/request-image.ts | 72 +++++----- .../attachment/attachment-local/src/store.ts | 80 +++++------ .../attachment-local/tests/index.spec.ts | 18 +-- ...anonical.spec.ts => normalization.spec.ts} | 136 +++++++++--------- .../tests/request-image-verification.spec.ts | 4 +- .../tests/request-image.spec.ts | 78 +++++----- .../attachment-local/tests/store.spec.ts | 42 +++--- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 4 +- packages/attachment/attachment/README.zh.md | 4 +- packages/attachment/attachment/src/index.ts | 42 ++---- packages/attachment/attachment/src/types.ts | 42 ++---- .../attachment/attachment/tests/index.spec.ts | 37 ++--- .../extensions/tool-cordis/src/api-catalog.ts | 32 ++--- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 6 +- packages/fs/tool-fs/README.zh.md | 6 +- packages/fs/tool-fs/src/read-image.ts | 44 +++--- packages/fs/tool-fs/tests/read-image.spec.ts | 38 ++--- .../command-goal/tests/command-goal.spec.ts | 9 +- .../apiproxy/tests/api-proxy-models.spec.ts | 15 +- .../commands/tests/commands.spec.ts | 10 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/README.zh.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 6 +- packages/llm/llm-deepseek/src/file-store.ts | 6 +- packages/llm/llm-deepseek/src/upload-index.ts | 26 ++-- .../llm/llm-deepseek/tests/adapter.e2e.ts | 15 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 25 ++-- .../llm-deepseek/tests/dynamic-config.spec.ts | 10 +- .../llm/llm-deepseek/tests/file-store.spec.ts | 4 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 4 +- .../llm-deepseek/tests/upload-index.spec.ts | 63 ++++---- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/config.ts | 2 +- packages/llm/llm-pi-ai/src/context.ts | 11 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 5 +- packages/llm/llm-pi-ai/tests/context.spec.ts | 20 +-- packages/llm/llm-pi-ai/tests/convert.spec.ts | 13 +- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 5 +- packages/llm/llm/src/content.ts | 2 +- .../mcp/mcp-client/tests/mcp-client.spec.ts | 9 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 5 +- scripts/gen-cordis-catalog.ts | 2 - scripts/gen-tool-catalog.ts | 4 +- scripts/test-invariants.ts | 3 +- 68 files changed, 612 insertions(+), 748 deletions(-) rename packages/attachment/attachment-local/src/{canonical.ts => normalization.ts} (77%) rename packages/attachment/attachment-local/tests/{canonical.spec.ts => normalization.spec.ts} (63%) diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index e95c7faa2a..1c6145c359 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: f0ef01de3b22c7132e7f698d0948a0da945726ba -2026-08-20-unified-image-request-pipeline.zh.md: b1a14ac418987ab8bfee9b731ad38cb48e21753e +2026-08-20-unified-image-request-pipeline.md: 6a3bae8a970677c32bbfb7966d2bc13d4e504804 +2026-08-20-unified-image-request-pipeline.zh.md: 10a4aed0b5ca9168c6a6ee4ec0258a210b50d531 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index f0ef01de3b..6a3bae8a97 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -1,4 +1,4 @@ -# Agent Note: Unified image masters, request versions, and provider files +# Agent Note: Unified normalized attachments, request versions, and provider files Status: implemented @@ -10,23 +10,23 @@ Durable image history, provider resolution, inline request size, and remote file ## Decision -The image path has two explicit versions. The attachment backend owns a provider-independent durable master. Each image-capable model route owns a deterministic request policy, and the attachment backend derives and caches the exact request version from the master. Session history contains only the master reference; inline bytes and provider file ids remain transient request projections. +The image path has two explicit versions. The attachment backend owns a provider-independent durable normalized attachment. Each image-capable model route owns a deterministic request policy, and the attachment backend derives and caches the exact request version from that attachment. Session history contains only the normalized attachment reference; inline bytes and provider file ids remain transient request projections. -### Provider-independent master +### Provider-independent normalized attachment -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Preparation applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `masterMaxDimension`, 2048px by default. `sourceWidth` and `sourceHeight` record orientation-applied dimensions when preparation reduces the raster. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `normalizedImageMaxDimension`, 2048px by default. When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization. -The master has an independent `masterMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both master limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. +The normalized attachment has an independent `normalizedImageMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both normalization limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. -Batch admission prepares and verifies every master once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. +Batch admission prepares and verifies every normalized attachment once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. ### Deterministic request versions -`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 master projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. -The `variantId` and cache path cover the master attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the master byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. `AttachmentStore.readImageRequests` preserves input order while the local implementation runs master and request transforms through one FIFO limiter. `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every master has been prepared. +The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(masterBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained masters are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles @@ -40,15 +40,15 @@ An upload is indexed only after the response returns a complete file object, mat ### Diagnostics -A 16-bit RGB or RGBA PNG is normal admitted input and converts to 8-bit sRGB/sRGBA. If local conversion fails, `read_image` names the path, detected 16-bit PNG, required canonical form, and manual conversion remedy. If DeepSeek rejects a normalized request version, the primary error names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. An ambiguous multi-image rejection lists every candidate. The raw provider body remains the error cause rather than the only visible message. +A 16-bit RGB or RGBA PNG is normal admitted input and converts to 8-bit sRGB/sRGBA. If local conversion fails, `read_image` names the path, detected 16-bit PNG, required normalized form, and manual conversion remedy. If DeepSeek rejects a normalized request version, the primary error names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. An ambiguous multi-image rejection lists every candidate. The raw provider body remains the error cause rather than the only visible message. Historical attachment objects that later disappear or fail integrity verification remain fail-loud. Durable quarantine and verified recovery require session events and are tracked by [Quarantine unreadable historical attachments](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md). ## Alternatives considered -**Use one 1MiB canonical image for storage and requests.** This makes model resolution determine durable image detail and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent master and request policies keep those responsibilities explicit. +**Use one 1MiB normalized attachment for storage and requests.** This makes model resolution determine durable image detail and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent normalization and request policies keep those responsibilities explicit. -**Reject images above provider dimensions or at the encoding quality floor.** A provider limit is route-specific and future requests may use another model. Proportional master preparation and request projection accept ordinary large images while bounding each later representation. +**Reject images above provider dimensions or at the encoding quality floor.** A provider limit is route-specific and future requests may use another model. Proportional normalization and request projection accept ordinary large images while bounding each later representation. **Treat PNG as a screenshot and reject 16-bit PNG.** File format does not reveal pixel complexity, and 16-bit RGB/RGBA is a convertible sample depth rather than an unsupported image type. Pixel sampling and post-conversion probes give the required facts. @@ -66,4 +66,4 @@ Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion ## Consequences -Durable masters consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests now depend on Files API availability; bounded stale-id recovery handles inconsistent remote state, while a general Files outage remains a visible request failure. Missing or corrupt durable masters still require the separate quarantine design. +Normalized attachments consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests now depend on Files API availability; bounded stale-id recovery handles inconsistent remote state, while a general Files outage remains a visible request failure. Missing or corrupt durable attachments still require the separate quarantine design. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index b1a14ac418..10a4aed0b5 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 统一图片主版本、请求版本与提供方文件 +# Agent Note: 统一规范化附件、请求版本与提供方文件 Status: implemented @@ -10,23 +10,23 @@ Status: implemented ## Decision -图片路径有两个显式版本。附件后端拥有提供方无关的持久主版本。每条支持图片的模型路由拥有确定性请求策略,附件后端从主版本派生并缓存确切请求版本。会话历史只包含主版本引用;内联字节和提供方文件 ID 都是瞬时请求投影。 +图片路径有两个显式版本。附件后端拥有提供方无关的持久规范化附件。每条支持图片的模型路由拥有确定性请求策略,附件后端从该附件派生并缓存确切请求版本。会话历史只包含规范化附件引用;内联字节和提供方文件 ID 都是瞬时请求投影。 -### 提供方无关的主版本 +### 提供方无关的规范化附件 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。处理会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`,默认 2048px。处理缩小光栅时,`sourceWidth` 和 `sourceHeight` 记录应用方向后的源尺寸。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`,默认 2048px。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。 -主版本有独立的 `masterMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 +规范化附件有独立的 `normalizedImageMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 -批量准入在发布任何成员前,为每张图片各准备并验证一次主版本。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 +批量准入在发布任何成员前,为每张图片各准备并验证一次规范化附件。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 ### 确定性请求版本 -`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 主版本在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 -`variantId` 和缓存路径覆盖主附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用主版本字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。`AttachmentStore.readImageRequests` 保持输入顺序,本地实现则通过一个 FIFO 限流器运行主版本和请求版本变换。`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部主版本准备完成后,批次仍按顺序发布。 +`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(主版本字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的主版本,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 ### 稳定句柄 @@ -46,9 +46,9 @@ Status: implemented ## Alternatives considered -**使用一份 1MiB 规范图片同时负责存储和请求。** 这种做法让模型分辨率决定持久图片细节,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的主版本和请求策略会明确区分这些职责。 +**使用一份 1MiB 规范化附件同时负责存储和请求。** 这种做法让模型分辨率决定持久图片细节,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的规范化和请求策略会明确区分这些职责。 -**拒绝超过提供方尺寸或达到编码质量下限的图片。** 提供方限制属于具体路由,未来请求可能改用另一个模型。按比例准备主版本和投影请求版本可以接纳普通大图,同时约束每种后续表示。 +**拒绝超过提供方尺寸或达到编码质量下限的图片。** 提供方限制属于具体路由,未来请求可能改用另一个模型。按比例规范化和投影请求版本可以接纳普通大图,同时约束每种后续表示。 **把 PNG 当作截图,并拒绝 16-bit PNG。** 文件格式不能说明像素复杂度,16-bit RGB/RGBA 是可转换位深,不是不支持的图片类型。像素采样和转换后探测能提供所需事实。 @@ -66,4 +66,4 @@ Status: implemented ## Consequences -持久主版本最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求现在依赖 Files API 可用性;有界的陈旧 ID 恢复会处理远端状态不一致,一般 Files 故障仍会成为可见请求失败。缺失或损坏的持久主版本仍需要单独的隔离设计。 +持久规范化附件最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求现在依赖 Files API 可用性;有界的陈旧 ID 恢复会处理远端状态不一致,一般 Files 故障仍会成为可见请求失败。缺失或损坏的持久附件仍需要单独的隔离设计。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 276fad138a..a7d0db5eec 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: d288fe3b85f1599da6ecef3dcf59c04c4e8c85d5 -config-catalog.zh.md: 266465fd09312c5dde9df4453c34f3aa774db7e2 +config-catalog.md: 8152b3c3280a7b85543d6cfeec850c8b3a25ca47 +config-catalog.zh.md: 95fd49e380e0cc9322fe8d12d0bdaf8dd310efac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d288fe3b85..8152b3c328 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -337,16 +337,16 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent master version. */ - masterMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent master version. */ - masterMaxBytes?: number - /** Maximum simultaneous master or request-image transformations in this service instance. */ + /** Long-edge pixel cap of the stored provider-independent normalized image. */ + normalizedImageMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + normalizedImageMaxBytes?: number + /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 266465fd09..95fd49e380 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -339,16 +339,16 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent master version. */ - masterMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent master version. */ - masterMaxBytes?: number - /** Maximum simultaneous master or request-image transformations in this service instance. */ + /** Long-edge pixel cap of the stored provider-independent normalized image. */ + normalizedImageMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + normalizedImageMaxBytes?: number + /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index ee14a0698f..b93c9ef1ca 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: 7c55bc192088f67ae7d117bc150aa0ea6fdf8b09 -attachment.zh.md: d5a140e283c1b7aa6ee5c991c2932ff65de0b88e +attachment.md: e6d0a53db2827a38a1535380319b6220aa37f0a4 +attachment.zh.md: 8328ec610d4d68624f75f00d6a397b13fdf31c4e diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 7c55bc1920..e6d0a53db2 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -18,7 +18,7 @@ type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' ``` ```ts type-equiv -/** Durable, serializable metadata for one immutable image object. */ +/** Durable, serializable reference to one immutable normalized image. */ interface ImageAttachmentRef { /** Opaque storage identifier; never a filesystem path or bearer URL. */ attachmentId: AttachmentId @@ -32,10 +32,14 @@ interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string - /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ - sourceWidth?: number - /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ - sourceHeight?: number + /** + * Input dimensions after applying EXIF orientation and before normalization + * scaling. Present only when normalization reduced the image. + */ + originalDimensions?: { + width: number + height: number + } } ``` @@ -52,7 +56,7 @@ interface ImageAttachmentLimits { } ``` -The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 20 MiB, 64,000,000 pixels, and 8192 pixels on either side. These source limits precede the independent 2048-pixel, 4 MiB master preparation stage. +The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 20 MiB, 64,000,000 pixels, and 8192 pixels on either side. These source limits precede the independent normalization stage, which limits the long edge to 2048 pixels and encoded data to 4 MiB by default. The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. @@ -100,12 +104,12 @@ interface ImageRequestPolicy { ``` ```ts type-equiv -/** Cached request version derived from one provider-independent master attachment. */ +/** Cached request version derived from one provider-independent normalized attachment. */ interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ + /** Cache and upload-index key over the attachment id, policy, and fixed encoder parameters. */ variantId: ImageVariantId - /** Durable master reference from which this request version was derived. */ - master: ImageAttachmentRef + /** Durable normalized attachment from which this request version was derived. */ + attachment: ImageAttachmentRef /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -121,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares a provider-independent 2048px, 4MiB master and atomically commits it before returning its reference. `saveImages()` prepares every validated master once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a master from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. `readImageRequests()` lets an implementation apply its configured transform concurrency to an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -149,48 +153,37 @@ abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one ordered image batch. * @param inputs - encoded images in owning-message order. - * @returns durable master references in the same order after every member succeeds. + * @returns durable normalized attachment references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a prepared master version of the submitted raster; - * the returned reference always describes the stored bytes, while `source` - * preserves the submitted raster's intrinsic facts for callers that report - * or map coordinates against the original. + * The returned reference describes the persisted normalized image. When + * normalization reduces the raster, its `originalDimensions` records the + * orientation-applied input dimensions. * @param input - encoded bytes, declared media type, and optional display name. - * @returns the durable content-addressed reference beside the submitted source facts. + * @returns the durable content-addressed normalized image reference. */ -abstract saveImage(input: SaveImageAttachment): Promise +abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and master reference. + * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Generate or read one deterministic model-request version from the stored master image. - * @param ref - durable provider-independent master reference. + * Generate or read one deterministic model-request version from the stored normalized image. + * @param ref - durable provider-independent normalized attachment reference. * @param policy - exact route pixel and encoded-byte budget. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise - -/** - * Generate or read an ordered batch of deterministic model-request versions. - * Implementations may use their own bounded transform concurrency while preserving input order. - * @param refs - durable provider-independent master references in request order. - * @param policy - exact route pixel and encoded-byte budget shared by the batch. - * @param signal - optional cancellation. - * @returns request versions in the same order as `refs`. - */ -async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index d5a140e283..8328ec610d 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -18,7 +18,7 @@ type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' ``` ```ts type-equiv -/** Durable, serializable metadata for one immutable image object. */ +/** Durable, serializable reference to one immutable normalized image. */ interface ImageAttachmentRef { /** Opaque storage identifier; never a filesystem path or bearer URL. */ attachmentId: AttachmentId @@ -32,10 +32,14 @@ interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string - /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ - sourceWidth?: number - /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ - sourceHeight?: number + /** + * Input dimensions after applying EXIF orientation and before normalization + * scaling. Present only when normalization reduced the image. + */ + originalDimensions?: { + width: number + height: number + } } ``` @@ -52,7 +56,7 @@ interface ImageAttachmentLimits { } ``` -本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 20 MiB、64,000,000 像素和单边 8192 像素。这些源文件限制先于独立的 2048 像素、4 MiB 主版本处理阶段执行。 +本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 20 MiB、64,000,000 像素和单边 8192 像素。这些源文件限制先于独立的规范化阶段执行;该阶段默认把长边限制为 2048 像素,把编码数据限制为 4 MiB。 引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。 @@ -100,12 +104,12 @@ interface ImageRequestPolicy { ``` ```ts type-equiv -/** Cached request version derived from one provider-independent master attachment. */ +/** Cached request version derived from one provider-independent normalized attachment. */ interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ + /** Cache and upload-index key over the attachment id, policy, and fixed encoder parameters. */ variantId: ImageVariantId - /** Durable master reference from which this request version was derived. */ - master: ImageAttachmentRef + /** Durable normalized attachment from which this request version was derived. */ + attachment: ImageAttachmentRef /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -121,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备提供方无关的 2048px、4MiB 主版本,并在返回引用前以原子方式提交。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的主版本,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的主版本。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。`readImageRequests()` 允许实现按自身配置的变换并发处理有序批次。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,默认同时执行两项变换。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -149,48 +153,37 @@ abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one ordered image batch. * @param inputs - encoded images in owning-message order. - * @returns durable master references in the same order after every member succeeds. + * @returns durable normalized attachment references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a prepared master version of the submitted raster; - * the returned reference always describes the stored bytes, while `source` - * preserves the submitted raster's intrinsic facts for callers that report - * or map coordinates against the original. + * The returned reference describes the persisted normalized image. When + * normalization reduces the raster, its `originalDimensions` records the + * orientation-applied input dimensions. * @param input - encoded bytes, declared media type, and optional display name. - * @returns the durable content-addressed reference beside the submitted source facts. + * @returns the durable content-addressed normalized image reference. */ -abstract saveImage(input: SaveImageAttachment): Promise +abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and master reference. + * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Generate or read one deterministic model-request version from the stored master image. - * @param ref - durable provider-independent master reference. + * Generate or read one deterministic model-request version from the stored normalized image. + * @param ref - durable provider-independent normalized attachment reference. * @param policy - exact route pixel and encoded-byte budget. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise - -/** - * Generate or read an ordered batch of deterministic model-request versions. - * Implementations may use their own bounded transform concurrency while preserving input order. - * @param refs - durable provider-independent master references in request order. - * @param policy - exact route pixel and encoded-byte budget shared by the batch. - * @param signal - optional cancellation. - * @returns request versions in the same order as `refs`. - */ -async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index 678de3e53f..0d2c35c8c6 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -363,8 +363,10 @@ interface ToolOutputMap { width: number; height: number; name?: string; - sourceWidth?: number; - sourceHeight?: number; + originalDimensions?: { + width: number; + height: number; + }; }; }; send_message: { diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index e5a4a66a3b..4aa32f078c 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -30,7 +30,7 @@ describe('ACP connection ownership', () => { it('disposal drains asynchronous assistant image delivery before releasing sessions', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index 7c0532e92d..ce6e93794f 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -13,7 +13,7 @@ import { type Stream, } from '@agentclientprotocol/sdk' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -99,7 +99,7 @@ class MemoryAttachmentStore extends AttachmentStore { if (input.data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') } - saveImage(input: SaveImageAttachment): Promise { + saveImage(input: SaveImageAttachment): Promise { this.saved.push(input) const digest = createHash('sha256').update(input.data).digest('hex') const ref: ImageAttachmentRef = { @@ -110,10 +110,7 @@ class MemoryAttachmentStore extends AttachmentStore { height: 1, } this.objects.set(ref.attachmentId, { ref, data: Uint8Array.from(input.data) }) - return Promise.resolve({ - ref, - source: { mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }, - }) + return Promise.resolve(ref) } async readImage(ref: ImageAttachmentRef): Promise { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index c9b229caf1..71e2a21e43 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -44,7 +44,7 @@ describe('ACP prompt lifecycle', () => { it('delivers a committed assistant image as verified ACP base64', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { @@ -68,7 +68,7 @@ describe('ACP prompt lifecycle', () => { it('preserves committed text/image/text order on the ACP wire', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' }) script.push([ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'block-end', index: 0, block: { type: 'text', text: 'before' } }, @@ -92,7 +92,7 @@ describe('ACP prompt lifecycle', () => { it('does not settle a prompt before ordered output delivery drains', async () => { const script: StreamChunk[][] = [] harness = await makeBridgeHarness({ script }) - const { ref } = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' }) script.push([ { type: 'block-start', index: 0, blockType: 'image' }, { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index a8bfa1b322..412e9a4cb6 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: d4831f864dbb061319008242395e2c8ff6d9f642 -README.zh.md: 45bddf47ea5f68c15778040de5b29817e8f62956 +README.md: 849363ce53c6186359ecad34aecb1c2a48f07441 +README.zh.md: f0fe90c2569f60df48998e46d5b05a0d024959df diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index d4831f864d..849363ce53 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent master. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `masterMaxDimension` (2048px by default). The master has its own `masterMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both master limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and a converted master are each fully decoded once. `saveImages` prepares and verifies every master once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored master under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It also executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the master id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. `readImageRequests` schedules batches through the service's FIFO limiter. `imageCompressionConcurrency` controls simultaneous master and request transforms from 1 through 8 and defaults to 2; file publication remains ordered after preparation. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. @@ -16,11 +16,11 @@ Indirectly, through durable replay of historical user images and structured mode #### KV Cache effect -Master preparation and request projection are deterministic. An unchanged master and route policy reuse identical cached request bytes on later turns. +Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. ## Known Limitations and Deferred Work - Objects are retained indefinitely; reference-aware garbage collection is deferred. - The local backend assumes the host and provider adapter share this filesystem service. - Animated GIF sources keep only their first frame; animation is outside the version-one image contract. -- The master and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future masters or request variants while existing objects stay valid. +- The normalization and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future normalized attachments or request variants while existing objects stay valid. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 45bddf47ea..f0fe90c256 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,9 +4,9 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的主版本:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `masterMaxDimension`(默认 2048px)。主版本有独立的 `masterMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个主版本上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的主版本各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次主版本,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 -请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的主版本缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选仍按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含主版本 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。`readImageRequests` 通过服务的 FIFO 限流器调度批次。`imageCompressionConcurrency` 控制同时执行的主版本和请求版本变换,范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 @@ -16,11 +16,11 @@ #### KV 缓存影响 -主版本准备和请求投影都是确定性的。主版本和路由策略不变时,之后各轮会复用相同的缓存请求字节。 +规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。 ## 已知限制与待完成工作 - 对象会无限期保留;基于引用的垃圾回收尚未实现。 - 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 - 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。 -- 主版本和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的主版本或请求变体产生新地址,已有对象保持有效。 +- 规范化和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的规范化附件或请求变体产生新地址,已有对象保持有效。 diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts index 963edda672..bf83d48cf9 100644 --- a/packages/attachment/attachment-local/src/encoding.ts +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -1,4 +1,4 @@ -/** Shared lazy candidate execution for master and request-image encoders. */ +/** Shared lazy candidate execution for normalization and request-image encoders. */ /** One encoded candidate carrying its complete bytes. */ export interface EncodedCandidate { diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 9007544047..e9a1145ba5 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -10,17 +10,16 @@ import type { ImageRequestPolicy, RequestImageAttachment, SaveImageAttachment, - SavedImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import type { MasterImagePolicy } from './canonical.ts' +import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' -export { isMasterImage, prepareMasterImage } from './canonical.ts' -export type { MasterImage, MasterImagePolicy } from './canonical.ts' +export { canPassThroughNormalization, normalizeImage } from './normalization.ts' +export type { NormalizedImage, NormalizationPolicy } from './normalization.ts' export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts' export type { PreparedImageFile } from './store.ts' export { readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts' @@ -36,13 +35,13 @@ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 /** Default per-side pixel cap for one submitted image. */ export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 /** - * Default long-edge target of the stored image master. A larger source + * Default long-edge target of the stored normalized image. A larger source * is admitted and downscaled to this edge, so admission bounds what rides * every later model request without refusing ordinary large sources. */ -export const DEFAULT_MASTER_MAX_DIMENSION = 2048 -/** Default independent safety cap for one stored master version. */ -export const DEFAULT_MASTER_MAX_BYTES = 4 * 1024 * 1024 +export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 2048 +/** Default independent safety cap for one stored normalized image. */ +export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024 /** Conservative default number of simultaneous native image transformations per store. */ export const DEFAULT_IMAGE_COMPRESSION_CONCURRENCY = 2 /** Maximum configurable native image transformations per store. */ @@ -62,11 +61,11 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent master version. */ - masterMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent master version. */ - masterMaxBytes?: number - /** Maximum simultaneous master or request-image transformations in this service instance. */ + /** Long-edge pixel cap of the stored provider-independent normalized image. */ + normalizedImageMaxDimension?: number + /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + normalizedImageMaxBytes?: number + /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number } @@ -140,8 +139,8 @@ export class LocalAttachmentStore extends AttachmentStore { maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), - masterMaxDimension: z.number().step(1).min(1).default(DEFAULT_MASTER_MAX_DIMENSION), - masterMaxBytes: z.number().step(1).min(1).default(DEFAULT_MASTER_MAX_BYTES), + normalizedImageMaxDimension: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION), + normalizedImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_BYTES), imageCompressionConcurrency: z.number().step(1).min(1).max(MAX_IMAGE_COMPRESSION_CONCURRENCY) .default(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY), }) @@ -149,8 +148,8 @@ export class LocalAttachmentStore extends AttachmentStore { /** Absolute versioned storage root. */ readonly root: string readonly imageLimits: ImageAttachmentLimits - /** Resolved provider-independent master-version storage policy. */ - readonly masterPolicy: Readonly + /** Resolved provider-independent normalization policy. */ + readonly normalizationPolicy: Readonly /** Resolved instance-level compression limit. */ readonly imageCompressionConcurrency: number private readonly compression: CompressionLimiter @@ -167,9 +166,9 @@ export class LocalAttachmentStore extends AttachmentStore { maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) - this.masterPolicy = Object.freeze({ - maxDimension: config.masterMaxDimension ?? DEFAULT_MASTER_MAX_DIMENSION, - maxBytes: config.masterMaxBytes ?? DEFAULT_MASTER_MAX_BYTES, + this.normalizationPolicy = Object.freeze({ + maxDimension: config.normalizedImageMaxDimension ?? DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + maxBytes: config.normalizedImageMaxBytes ?? DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) const compressionConcurrency = config.imageCompressionConcurrency ?? DEFAULT_IMAGE_COMPRESSION_CONCURRENCY if (!Number.isSafeInteger(compressionConcurrency) @@ -184,22 +183,22 @@ export class LocalAttachmentStore extends AttachmentStore { } async validateImage(input: SaveImageAttachment): Promise { - await this.compression.run(() => validateImageFile(input, this.imageLimits, this.masterPolicy)) + await this.compression.run(() => validateImageFile(input, this.imageLimits, this.normalizationPolicy)) } override async saveImages(inputs: readonly SaveImageAttachment[]): Promise { this.validateImageBatch(inputs) const prepared = await Promise.all(inputs.map(input => this.compression.run( - () => prepareImageFile(input, this.imageLimits, this.masterPolicy), + () => prepareImageFile(input, this.imageLimits, this.normalizationPolicy), ))) const refs: ImageAttachmentRef[] = [] - for (const image of prepared) refs.push((await commitPreparedImageFile(this.root, image)).ref) + for (const image of prepared) refs.push(await commitPreparedImageFile(this.root, image)) return refs } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { const prepared = await this.compression.run( - () => prepareImageFile(input, this.imageLimits, this.masterPolicy), + () => prepareImageFile(input, this.imageLimits, this.normalizationPolicy), ) return commitPreparedImageFile(this.root, prepared) } @@ -216,18 +215,10 @@ export class LocalAttachmentStore extends AttachmentStore { return this.requestVersion(ref, policy, undefined, signal) } - override async readImageRequests( - refs: readonly ImageAttachmentRef[], - policy: ImageRequestPolicy, - signal?: AbortSignal, - ): Promise { - return Promise.all(refs.map(ref => this.requestVersion(ref, policy, undefined, signal))) - } - private requestVersion( ref: ImageAttachmentRef, policy: ImageRequestPolicy, - master: StoredImageAttachment | undefined, + stored: StoredImageAttachment | undefined, signal: AbortSignal | undefined, ): Promise { signal?.throwIfAborted() @@ -241,7 +232,7 @@ export class LocalAttachmentStore extends AttachmentStore { if (operation === undefined) { const shared = new SharedRequest(sharedSignal => this.compression.run(async () => readRequestImageFile( this.root, - master ?? await this.readImage(ref, sharedSignal), + stored ?? await this.readImage(ref, sharedSignal), policy, sharedSignal, ))) diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/normalization.ts similarity index 77% rename from packages/attachment/attachment-local/src/canonical.ts rename to packages/attachment/attachment-local/src/normalization.ts index 513e5bbcc7..acfec63c0f 100644 --- a/packages/attachment/attachment-local/src/canonical.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -1,4 +1,4 @@ -/** Deterministic provider-independent master-image encoding. */ +/** Deterministic provider-independent image normalization. */ import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' @@ -7,23 +7,23 @@ import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' import { detectImage } from './image.ts' import type { DetectedImage } from './image.ts' -/** Deployment-resolved storage policy for the provider-independent master version. */ -export interface MasterImagePolicy { +/** Deployment-resolved policy for the persisted normalized attachment. */ +export interface NormalizationPolicy { /** Long-edge cap in pixels; larger sources are downscaled proportionally. */ maxDimension: number - /** Independent safety cap for encoded master bytes. */ + /** Independent safety cap for encoded normalized image bytes. */ maxBytes: number } -/** Master bytes beside the facts recorded by a durable reference. */ -export interface MasterImage { +/** Normalized bytes beside the facts recorded by a durable reference. */ +export interface NormalizedImage { data: Uint8Array mediaType: ImageMediaType width: number height: number } -const MASTER_QUALITIES = [85, 80, 75] as const +const NORMALIZATION_QUALITIES = [85, 80, 75] as const const LOW_COLOUR_SAMPLE_EDGE = 128 const LOW_COLOUR_LIMIT = 256 const MIN_SCALE_STEP = 0.9 @@ -34,7 +34,7 @@ async function encode( mediaType: 'image/png' | 'image/jpeg' | 'image/webp', quality?: number, palette = true, -): Promise { +): Promise { const encoded = mediaType === 'image/png' ? pipeline.png({ compressionLevel: 9, palette }) : mediaType === 'image/webp' @@ -45,13 +45,17 @@ async function encode( } /** - * Whether bytes already satisfy the master-version storage contract. + * Whether bytes already satisfy the normalization requirements. * @param detected - fully decoded source facts. * @param bytes - encoded source length. - * @param policy - resolved master limits. + * @param policy - resolved normalization limits. * @returns whether the source can pass through byte-identically. */ -export function isMasterImage(detected: DetectedImage, bytes: number, policy: MasterImagePolicy): boolean { +export function canPassThroughNormalization( + detected: DetectedImage, + bytes: number, + policy: NormalizationPolicy, +): boolean { return detected.mediaType !== 'image/gif' && !detected.animated && !detected.carriesMetadata @@ -87,8 +91,11 @@ export async function hasLowColourCount(pipeline: Sharp): Promise { return true } -/** Assert that a re-encoded master is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ -async function verifyMaster(image: MasterImage, expectedAlpha: boolean | undefined): Promise { +/** Assert that a normalized output is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ +async function verifyNormalizedImage( + image: NormalizedImage, + expectedAlpha: boolean | undefined, +): Promise { const detected = await detectImage(image.data) if (detected.mediaType !== image.mediaType || detected.width !== image.width @@ -99,7 +106,7 @@ async function verifyMaster(image: MasterImage, expectedAlpha: boolean | undefin || detected.space !== 'srgb' || (expectedAlpha !== undefined && detected.hasAlpha !== expectedAlpha)) { throw new AttachmentError( - 'Canonical image conversion did not produce a single-frame 8-bit sRGB image with matching metadata.', + 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', 'ATTACHMENT_WRITE_FAILED', ) } @@ -130,36 +137,36 @@ function encodingAttemptsAtSize( height: number, hasAlpha: boolean, lowColour: boolean, -): Array<() => Promise> { +): Array<() => Promise> { const prepared = preparedPipeline(data, width, height) - const webp = MASTER_QUALITIES.map(quality => ( + const webp = NORMALIZATION_QUALITIES.map(quality => ( () => encode(prepared.clone(), 'image/webp', quality) )) if (lowColour) { return [() => encode(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] } if (hasAlpha) return webp - return MASTER_QUALITIES.map(quality => ( + return NORMALIZATION_QUALITIES.map(quality => ( () => encode(prepared.clone(), 'image/jpeg', quality) )) } /** - * Produce the 2048px provider-independent master version of one fully decoded source. + * Produce the persisted provider-independent normalized version of one fully decoded source. * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, - * and inside both master limits. Re-encoding never removes transparency. After the fixed + * and inside both normalization limits. Re-encoding never removes transparency. After the fixed * quality floor is reached, dimensions continue shrinking until the independent byte cap holds. * @param data - complete admitted source bytes. * @param detected - fully decoded source facts. - * @param policy - resolved independent master limits. - * @returns verified provider-independent master bytes and metadata. + * @param policy - resolved independent normalization limits. + * @returns verified provider-independent normalized bytes and metadata. */ -export async function prepareMasterImage( +export async function normalizeImage( data: Uint8Array, detected: DetectedImage, - policy: MasterImagePolicy, -): Promise { - if (isMasterImage(detected, data.byteLength, policy)) { + policy: NormalizationPolicy, +): Promise { + if (canPassThroughNormalization(detected, data.byteLength, policy)) { return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } } try { @@ -174,7 +181,7 @@ export async function prepareMasterImage( policy.maxBytes, ) if (!isExhaustedEncoding(encoded)) { - return await verifyMaster(encoded, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) + return await verifyNormalizedImage(encoded, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) } if (width === 1 && height === 1) break const sizeScale = Math.sqrt(policy.maxBytes / encoded.smallest.data.byteLength) * 0.95 @@ -190,10 +197,10 @@ export async function prepareMasterImage( ? `${detected.depth === 'ushort' ? '16-bit' : detected.depth} PNG` : `${detected.depth} ${detected.mediaType.slice('image/'.length).toUpperCase()}` throw new AttachmentError( - `The ${source} could not be converted to the canonical 8-bit sRGB form.`, + `The ${source} could not be converted to the normalized 8-bit sRGB form.`, 'ATTACHMENT_WRITE_FAILED', { cause: error }, ) } - throw new AttachmentError('Image cannot be encoded within the configured master-image byte cap.', 'IMAGE_TOO_LARGE') + throw new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') } diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index c37a473d4c..b7c9068bfb 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -12,12 +12,12 @@ import type { RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { hasLowColourCount } from './canonical.ts' +import { hasLowColourCount } from './normalization.ts' import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' import { detectImage, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ -export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v3' +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v4' /** DeepSeek request versions normally fit at these two preferred qualities. */ export const REQUEST_IMAGE_QUALITIES = [85, 80] as const @@ -80,10 +80,10 @@ function validatePolicy(policy: ImageRequestPolicy): void { checkedInteger(policy.maxBytes, 'Image request maxBytes') } -function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): string { +function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string { return JSON.stringify({ transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION, - masterAttachmentId: master.attachmentId, + attachmentId: attachment.attachmentId, routePixelBudget: policy.maxPixels, encodedByteBudget: policy.maxBytes, encoding: { @@ -97,25 +97,25 @@ function descriptor(master: ImageAttachmentRef, policy: ImageRequestPolicy): str } /** - * Complete deterministic identity for one master and route-owned request policy. - * @param master - provider-independent durable master reference. + * Complete deterministic identity for one attachment and route-owned request policy. + * @param attachment - provider-independent durable normalized attachment reference. * @param policy - route-owned pixel and byte policy. * @returns branded digest over every request transform input. */ export function requestImageVariantId( - master: ImageAttachmentRef, + attachment: ImageAttachmentRef, policy: ImageRequestPolicy, ): ReturnType { - return ImageVariantId(`sha256:${digest(descriptor(master, policy))}`) + return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`) } -function pipeline(master: StoredImageAttachment, width: number, height: number): Sharp { - return sourcePipeline(master) +function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp { + return sourcePipeline(attachment) .resize({ width, height, fit: 'inside', withoutEnlargement: true }) } -function sourcePipeline(master: StoredImageAttachment): Sharp { - return sharp(master.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') +function sourcePipeline(attachment: StoredImageAttachment): Sharp { + return sharp(attachment.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') } async function encoded( @@ -134,13 +134,13 @@ async function encoded( } function encodingAttempts( - master: StoredImageAttachment, + attachment: StoredImageAttachment, width: number, height: number, hasAlpha: boolean, lowColour: boolean, ): Array<() => Promise> { - const prepared = pipeline(master, width, height) + const prepared = pipeline(attachment, width, height) const webp = REQUEST_IMAGE_QUALITIES.map(quality => ( () => encoded(prepared.clone(), 'image/webp', quality) )) @@ -152,25 +152,25 @@ function encodingAttempts( } async function createRequestImage( - master: StoredImageAttachment, + attachment: StoredImageAttachment, policy: ImageRequestPolicy, hasAlpha: boolean, ): Promise { - let dimensions = requestImageDimensions(master.ref.width, master.ref.height, policy.maxPixels) - if (dimensions.width === master.ref.width - && dimensions.height === master.ref.height - && master.data.byteLength <= policy.maxBytes) { + let dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + if (dimensions.width === attachment.ref.width + && dimensions.height === attachment.ref.height + && attachment.data.byteLength <= policy.maxBytes) { return { - data: master.data, - mediaType: master.ref.mediaType, - width: master.ref.width, - height: master.ref.height, + data: attachment.data, + mediaType: attachment.ref.mediaType, + width: attachment.ref.width, + height: attachment.ref.height, } } - const lowColour = await hasLowColourCount(sourcePipeline(master)) + const lowColour = await hasLowColourCount(sourcePipeline(attachment)) for (;;) { const encodedVersion = await encodeFirstWithinLimit( - encodingAttempts(master, dimensions.width, dimensions.height, hasAlpha, lowColour), + encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha, lowColour), policy.maxBytes, ) if (!isExhaustedEncoding(encodedVersion)) return encodedVersion @@ -190,7 +190,7 @@ function cachePath(root: string, hash: string): string { async function readCached( path: string, - master: StoredImageAttachment, + attachment: StoredImageAttachment, policy: ImageRequestPolicy, expectedAlpha: boolean, signal?: AbortSignal, @@ -198,7 +198,7 @@ async function readCached( try { const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) - const maximum = requestImageDimensions(master.ref.width, master.ref.height, policy.maxPixels) + const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width > maximum.width || detected.height > maximum.height || detected.hasAlpha !== expectedAlpha) return undefined @@ -240,33 +240,33 @@ async function writeCached(path: string, data: Uint8Array): Promise { /** * Generate or reuse one request image below the local attachment root. * @param root - absolute versioned attachment storage root. - * @param master - verified stored master bytes and reference. + * @param attachment - verified normalized attachment bytes and reference. * @param policy - exact route request-image policy. * @param signal - optional cancellation for cache I/O and image transformation. * @returns verified request bytes and deterministic variant identity. */ export async function readRequestImageFile( root: string, - master: StoredImageAttachment, + attachment: StoredImageAttachment, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() validatePolicy(policy) - const source = await probeImage(master.data) - const variantId = requestImageVariantId(master.ref, policy) + const source = await probeImage(attachment.data) + const variantId = requestImageVariantId(attachment.ref, policy) const hash = String(variantId).slice('sha256:'.length) const path = cachePath(root, hash) - const cached = await readCached(path, master, policy, source.hasAlpha, signal) - const created = cached ?? await createRequestImage(master, policy, source.hasAlpha) - const version = cached ?? (created.data === master.data + const cached = await readCached(path, attachment, policy, source.hasAlpha, signal) + const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha) + const version = cached ?? (created.data === attachment.data ? { ...created, hasAlpha: source.hasAlpha } : await verifyRequestImage(created, source.hasAlpha)) signal?.throwIfAborted() - if (cached === undefined && version.data !== master.data) await writeCached(path, version.data) + if (cached === undefined && version.data !== attachment.data) await writeCached(path, version.data) return { variantId, - master: master.ref, + attachment: attachment.ref, data: version.data, mediaType: version.mediaType, bytes: version.data.byteLength, diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index ba45256416..5fbb8e9201 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -12,12 +12,10 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, - SavedImageAttachment, - SourceImageInfo, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { prepareMasterImage } from './canonical.ts' -import type { MasterImagePolicy } from './canonical.ts' +import { normalizeImage } from './normalization.ts' +import type { NormalizationPolicy } from './normalization.ts' import { detectImage, probeImage } from './image.ts' import type { DetectedImage } from './image.ts' @@ -52,71 +50,69 @@ async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits, -): Promise<{ detected: DetectedImage; source: SourceImageInfo }> { +): Promise { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension }) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') - return { - detected, - source: { mediaType: detected.mediaType, bytes: data.byteLength, width: detected.width, height: detected.height }, - } + return detected } /** * Run the full admission policy for one image without touching storage, - * including master-version preparation: a batch whose members all validate - * cannot later be refused by the master byte cap during publication. + * including normalization: a batch whose members all validate cannot later + * be refused by the normalized image byte cap during publication. * @param input - encoded bytes and declared metadata. * @param limits - resolved source admission policy. - * @param policy - resolved master-version storage policy. - * @returns completion after the raster has been decoded and its master version proven to fit. + * @param policy - resolved normalization policy. + * @returns completion after the raster has been decoded and its normalized version proven to fit. */ export async function validateImageFile( input: SaveImageAttachment, limits: ImageAttachmentLimits, - policy: MasterImagePolicy, + policy: NormalizationPolicy, ): Promise { await prepareImageFile(input, limits, policy) } -/** Fully prepared master object, verified before any batch member is persisted. */ -export interface PreparedImageFile extends SavedImageAttachment { - /** Deterministic master bytes whose digest is {@link ref.attachmentId}. */ +/** Fully prepared normalized object, verified before any batch member is persisted. */ +export interface PreparedImageFile { + /** Deterministic normalized bytes whose digest is {@link ref.attachmentId}. */ data: Uint8Array + /** Durable reference describing {@link data}. */ + ref: ImageAttachmentRef } /** * Decode, normalize, and verify one submitted image without touching storage. * @param input - submitted encoded bytes and declared media type. * @param limits - source admission policy. - * @param policy - independent master-version storage policy. + * @param policy - independent normalization policy. * @returns immutable reference facts beside bytes ready for atomic publication. */ export async function prepareImageFile( input: SaveImageAttachment, limits: ImageAttachmentLimits, - policy: MasterImagePolicy, + policy: NormalizationPolicy, ): Promise { if (input.data.byteLength > limits.maxImageBytes) { throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') } - const { detected, source } = await inspectMetadata(input.data, input.mediaType, limits) - const master = await prepareMasterImage(input.data, detected, policy) - const sha256 = digest(master.data) + const detected = await inspectMetadata(input.data, input.mediaType, limits) + const normalized = await normalizeImage(input.data, detected, policy) + const sha256 = digest(normalized.data) const name = displayName(input.name) - const downscaled = source.width !== master.width || source.height !== master.height + const downscaled = detected.width !== normalized.width || detected.height !== normalized.height return { - data: master.data, + data: normalized.data, ref: { attachmentId: AttachmentId(`sha256:${sha256}`), - mediaType: master.mediaType, - width: master.width, - height: master.height, - bytes: master.data.byteLength, + mediaType: normalized.mediaType, + width: normalized.width, + height: normalized.height, + bytes: normalized.data.byteLength, ...(name !== undefined ? { name } : {}), - ...downscaled ? { sourceWidth: source.width, sourceHeight: source.height } : {}, + ...downscaled ? { originalDimensions: { width: detected.width, height: detected.height } } : {}, }, - source, } } @@ -180,18 +176,18 @@ async function ensureDurableHome(path: string): Promise { } /** - * Publish one already verified master below a versioned attachment root. + * Publish one already verified normalized image below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. - * @param prepared - deterministic master bytes, reference, and source facts. - * @returns durable content-addressed reference beside the submitted source facts. + * @param prepared - deterministic normalized bytes and reference. + * @returns durable content-addressed normalized image reference. */ export async function commitPreparedImageFile( root: string, prepared: PreparedImageFile, -): Promise { - const master = prepared.data +): Promise { + const normalized = prepared.data const sha256 = ensureReference(prepared.ref) - if (digest(master) !== sha256 || master.byteLength !== prepared.ref.bytes) { + if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) { throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT') } const bucket = join(root, 'objects', sha256.slice(0, 2)) @@ -207,7 +203,7 @@ export async function commitPreparedImageFile( let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(master) + await handle.writeFile(normalized) await handle.sync() await handle.close() handle = undefined @@ -242,7 +238,7 @@ export async function commitPreparedImageFile( if (error instanceof AttachmentError) throw error throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } - return { ref: prepared.ref, source: prepared.source } + return prepared.ref } /** @@ -250,15 +246,15 @@ export async function commitPreparedImageFile( * @param root - absolute `DSH_HOME/attachments/v1` root. * @param input - submitted encoded bytes and declared media type. * @param limits - resolved source admission policy. - * @param policy - resolved master-version storage policy. - * @returns durable content-addressed reference beside submitted source facts. + * @param policy - resolved normalization policy. + * @returns durable content-addressed normalized image reference. */ export async function saveImageFile( root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits, - policy: MasterImagePolicy, -): Promise { + policy: NormalizationPolicy, +): Promise { return commitPreparedImageFile(root, await prepareImageFile(input, limits, policy)) } diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index c3c7693614..f8deea3c5c 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -6,8 +6,8 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import sharp from 'sharp' import LocalAttachmentStore, { - DEFAULT_MASTER_MAX_BYTES, - DEFAULT_MASTER_MAX_DIMENSION, + DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, + DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, DEFAULT_IMAGE_COMPRESSION_CONCURRENCY, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_IMAGE_DIMENSION, @@ -32,9 +32,9 @@ describe('local attachment service', () => { maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) - expect(service.masterPolicy).toEqual({ - maxDimension: DEFAULT_MASTER_MAX_DIMENSION, - maxBytes: DEFAULT_MASTER_MAX_BYTES, + expect(service.normalizationPolicy).toEqual({ + maxDimension: DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY) }) @@ -55,7 +55,7 @@ describe('local attachment service', () => { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) - const { ref } = await service.saveImage({ data, mediaType: 'image/png' }) + const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) } finally { await rm(dshHome, { recursive: true, force: true }) @@ -86,7 +86,7 @@ describe('local attachment service', () => { } }) - it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit master object', async (channels) => { + it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit normalized object', async (channels) => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-16-bit-')) try { const service = new LocalAttachmentStore(new Context(), { dshHome }) @@ -95,7 +95,7 @@ describe('local attachment service', () => { }).toColourspace('rgb16').png().toBuffer()) const saved = await service.saveImage({ data: source, mediaType: 'image/png' }) - const stored = await service.readImage(saved.ref) + const stored = await service.readImage(saved) const metadata = await sharp(stored.data).metadata() expect(stored.data).not.toEqual(source) @@ -108,7 +108,7 @@ describe('local attachment service', () => { it('prepares every batch member before any write', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-')) try { - const service = new LocalAttachmentStore(new Context(), { dshHome, masterMaxBytes: 1 }) + const service = new LocalAttachmentStore(new Context(), { dshHome, normalizedImageMaxBytes: 1 }) const valid = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts similarity index 63% rename from packages/attachment/attachment-local/tests/canonical.spec.ts rename to packages/attachment/attachment-local/tests/normalization.spec.ts index 8aa30511d6..4b530988d1 100644 --- a/packages/attachment/attachment-local/tests/canonical.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import sharp from 'sharp' -import { hasLowColourCount, isMasterImage, prepareMasterImage } from '../src/canonical.ts' -import type { MasterImagePolicy } from '../src/canonical.ts' +import { hasLowColourCount, canPassThroughNormalization, normalizeImage } from '../src/normalization.ts' +import type { NormalizationPolicy } from '../src/normalization.ts' import { detectImage } from '../src/image.ts' -const POLICY: MasterImagePolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 } /** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */ function noisePixels(width: number, height: number): Uint8Array { @@ -31,29 +31,29 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | return new Uint8Array(await image.toFormat(format, format === 'webp' && alpha ? { lossless: true } : {}).toBuffer()) } -describe('isMasterImage', () => { +describe('canPassThroughNormalization', () => { it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => { const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false } - expect(isMasterImage({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) - expect(isMasterImage({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) - expect(isMasterImage({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) + expect(canPassThroughNormalization({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) }) }) -describe('prepareMasterImage', () => { - it('passes an already-canonical source through byte-identically', async () => { +describe('normalizeImage', () => { + it('passes an already-normalized source through byte-identically', async () => { const data = await flatImage(6, 4, 'webp') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).toBe(data) - expect(canonical).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 }) + expect(normalized.data).toBe(data) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 }) }) it.each([3, 4] as const)('converts a 16-bit %s-channel PNG to 8-bit sRGB without passthrough', async (channels) => { @@ -63,11 +63,11 @@ describe('prepareMasterImage', () => { const detected = await detectImage(data) expect(detected).toMatchObject({ depth: 'ushort', space: 'rgb16', hasAlpha: channels === 4 }) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).not.toBe(data) - expect(canonical.data).not.toEqual(data) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ + expect(normalized.data).not.toBe(data) + expect(normalized.data).not.toEqual(data) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ depth: 'uchar', space: 'srgb', hasAlpha: channels === 4, width: 7, height: 5, }) }) @@ -76,19 +76,19 @@ describe('prepareMasterImage', () => { const data = await flatImage(10, 6, 'png') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) - expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) - const again = await prepareMasterImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) - expect(again.data).toEqual(canonical.data) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + const again = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + expect(again.data).toEqual(normalized.data) }) - it('re-encodes the canonical output of a resize into itself (idempotence)', async () => { + it('re-encodes the normalized output of a resize into itself (idempotence)', async () => { const data = await flatImage(10, 6, 'png') - const first = await prepareMasterImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const first = await normalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) - const second = await prepareMasterImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const second = await normalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(second.data).toBe(first.data) }) @@ -97,19 +97,19 @@ describe('prepareMasterImage', () => { const data = await flatImage(6, 4, 'gif') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.mediaType).toBe('image/png') - await expect(detectImage(canonical.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + expect(normalized.mediaType).toBe('image/png') + await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) }) it('keeps a low-colour alpha source on PNG when the budget holds', async () => { const data = await flatImage(9, 5, 'webp', true) const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) - expect(canonical).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) }) it('retains an all-opaque alpha channel while converting a low-colour image', async () => { @@ -117,13 +117,13 @@ describe('prepareMasterImage', () => { create: { width: 10, height: 6, channels: 4, background: { r: 12, g: 200, b: 64, alpha: 1 } }, }).png().toBuffer()) - const canonical = await prepareMasterImage(data, await detectImage(data), { + const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes, }) - expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ hasAlpha: true }) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) }) it('keeps transparency when the byte cap requires another encoding and smaller dimensions', async () => { @@ -140,20 +140,20 @@ describe('prepareMasterImage', () => { } const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer()) - const canonical = await prepareMasterImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) + const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) - expect(canonical.data.byteLength).toBeLessThanOrEqual(1_024) - expect(canonical.width).toBeLessThan(side) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) + expect(normalized.data.byteLength).toBeLessThanOrEqual(1_024) + expect(normalized.width).toBeLessThan(side) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) }) it('re-encodes an oversized photographic JPEG as JPEG', async () => { const data = await noiseImage(64, 32, 'jpeg') const detected = await detectImage(data) - const canonical = await prepareMasterImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) - expect(canonical).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) }) it('classifies a photographic PNG by pixels and uses an opaque photographic encoding', async () => { @@ -174,21 +174,21 @@ describe('prepareMasterImage', () => { const detected = await detectImage(data) const budget = { maxDimension: 128, maxBytes: POLICY.maxBytes } - const canonical = await prepareMasterImage(data, detected, budget) + const normalized = await normalizeImage(data, detected, budget) - expect(canonical.mediaType).toBe('image/jpeg') - expect(canonical).toMatchObject({ width: 128, height: 128 }) - expect(canonical.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) + expect(normalized.mediaType).toBe('image/jpeg') + expect(normalized).toMatchObject({ width: 128, height: 128 }) + expect(normalized.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) }) it('shrinks dimensions after the quality floor instead of refusing an oversized encoding', async () => { const data = await noiseImage(64, 64, 'png') - const canonical = await prepareMasterImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) + const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) - expect(canonical.data.byteLength).toBeLessThanOrEqual(512) - expect(canonical.width).toBeLessThan(64) - expect(canonical.height).toBeLessThan(64) + expect(normalized.data.byteLength).toBeLessThanOrEqual(512) + expect(normalized.width).toBeLessThan(64) + expect(normalized.height).toBeLessThan(64) }) it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => { @@ -199,11 +199,11 @@ describe('prepareMasterImage', () => { // Orientation 6 rotates 90°: the perceived source is 2x4. expect(detected).toMatchObject({ width: 2, height: 4, carriesMetadata: true }) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).not.toBe(data) - expect(canonical).toMatchObject({ width: 2, height: 4 }) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false }) + expect(normalized.data).not.toBe(data) + expect(normalized).toMatchObject({ width: 2, height: 4 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false }) }) it('re-encodes an in-budget image with an ICC profile and strips the profile', async () => { @@ -213,10 +213,10 @@ describe('prepareMasterImage', () => { const detected = await detectImage(data) expect(detected.carriesMetadata).toBe(true) - const canonical = await prepareMasterImage(data, detected, POLICY) + const normalized = await normalizeImage(data, detected, POLICY) - expect(canonical.data).not.toBe(data) - await expect(detectImage(canonical.data)).resolves.toMatchObject({ carriesMetadata: false }) + expect(normalized.data).not.toBe(data) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ carriesMetadata: false }) }) it('maps an encoder fault on undecodable bytes to a storage failure', async () => { @@ -224,10 +224,10 @@ describe('prepareMasterImage', () => { mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false, depth: 'ushort', space: 'rgb16', hasAlpha: true, } as const - await expect(prepareMasterImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', - message: 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.', + message: 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', }) }) @@ -245,23 +245,23 @@ describe('prepareMasterImage', () => { hasAlpha: false, } as const - await expect(prepareMasterImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', - message: `The ${source} could not be converted to the canonical 8-bit sRGB form.`, + message: `The ${source} could not be converted to the normalized 8-bit sRGB form.`, }) }) - it('rejects a converted master whose verified alpha metadata disagrees with the source facts', async () => { + it('rejects a converted normalized image whose verified alpha metadata disagrees with the source facts', async () => { const data = await flatImage(8, 8, 'png', true) const detected = await detectImage(data) - await expect(prepareMasterImage(data, { ...detected, hasAlpha: false }, { + await expect(normalizeImage(data, { ...detected, hasAlpha: false }, { maxDimension: 4, maxBytes: POLICY.maxBytes, })).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', - message: 'Canonical image conversion did not produce a single-frame 8-bit sRGB image with matching metadata.', + message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', }) }) }) @@ -339,13 +339,13 @@ describe('hasLowColourCount', () => { `)).removeAlpha().png().toBuffer()) - const master = await prepareMasterImage(source, await detectImage(source), { + const normalized = await normalizeImage(source, await detectImage(source), { maxDimension: 512, maxBytes: POLICY.maxBytes, }) - const stats = await sharp(master.data).greyscale().stats() + const stats = await sharp(normalized.data).greyscale().stats() - expect(master).toMatchObject({ mediaType: 'image/png', width: 512, height: 256 }) + expect(normalized).toMatchObject({ mediaType: 'image/png', width: 512, height: 256 }) expect(stats.channels[0]?.min).toBeLessThan(80) expect(stats.channels[0]?.max).toBeGreaterThan(240) }) diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts index aae96e0b1d..32bc005c94 100644 --- a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -35,10 +35,10 @@ describe('request image verification', () => { const source = new Uint8Array(await sharp({ create: { width: 64, height: 32, channels: 3, background: { r: 12, g: 34, b: 56 } }, }).png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) control.mismatch = true - await expect(attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index c38e8ce137..7052da89e8 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -54,43 +54,45 @@ describe('request image dimensions', () => { }) describe('local request-image cache', () => { - it('passes through an in-budget master and reads a request batch in input order', async () => { + it('passes through an in-budget attachment and composes ordered request reads', async () => { const attachments = await store() - const first = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref - const second = (await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })).ref - const firstMaster = await attachments.readImage(first) + const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) + const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' }) + const firstStored = await attachments.readImage(first) const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 } const request = await attachments.readImageRequest(first, policy) - const batch = await attachments.readImageRequests([first, second], policy) + const batch = await Promise.all([first, second].map( + attachment => attachments.readImageRequest(attachment, policy), + )) - expect(request.data).toEqual(firstMaster.data) - expect(batch.map(value => value.master.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) + expect(request.data).toEqual(firstStored.data) + expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) }) it('rejects invalid request policies', async () => { const attachments = await store() - const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(master, { maxPixels: 0, maxBytes: 100 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 })) .rejects.toThrow('Image request maxPixels must be a positive integer') - await expect(attachments.readImageRequest(master, { maxPixels: 100, maxBytes: 0 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 })) .rejects.toThrow('Image request maxBytes must be a positive integer') }) it('refuses a one-pixel request that cannot meet the encoded-byte budget', async () => { const attachments = await store() - const master = (await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(master, { maxPixels: 1, maxBytes: 1 })) + await expect(attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })) .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) }) it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { const attachments = await store() - const master = (await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } - const initial = await attachments.readImageRequest(master, policy) + const initial = await attachments.readImageRequest(attachment, policy) const hash = String(initial.variantId).slice('sha256:'.length) const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash) const noisyPixels = new Uint8Array(64 * 64 * 3) @@ -124,19 +126,19 @@ describe('local request-image cache', () => { Uint8Array.of(1, 2, 3), ]) { await writeFile(path, invalid) - const regenerated = await attachments.readImageRequest(master, policy) + const regenerated = await attachments.readImageRequest(attachment, policy) expect(regenerated.data).toEqual(initial.data) } }) it('derives stable square and wide previews and separates route budgets in the cache key', async () => { const attachments = await store() - const square = (await attachments.saveImage({ + const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png', name: 'square.png', - })).ref - const wide = (await attachments.saveImage({ + }) + const wide = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png', - })).ref + }) const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) @@ -178,8 +180,8 @@ describe('local request-image cache', () => { const alphaSource = new Uint8Array(await sharp(alphaPixels, { raw: { width: side, height: side, channels: 4 }, }).png().toBuffer()) - const photo = (await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })).ref - const alpha = (await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })).ref + const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' }) + const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' }) const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 }) const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) @@ -195,9 +197,9 @@ describe('local request-image cache', () => { const source = new Uint8Array(await sharp({ create: { width: 64, height: 32, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, }).toColourspace('rgb16').png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) expect(request.width * request.height).toBeLessThanOrEqual(16 * 16) @@ -211,9 +213,9 @@ describe('local request-image cache', () => { const source = new Uint8Array(await sharp({ create: { width: 64, height: 32, channels: 4, background: { r: 12, g: 34, b: 56, alpha: 1 } }, }).png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: true }) }) @@ -232,9 +234,9 @@ describe('local request-image cache', () => { const source = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 }, }).png().toBuffer()) - const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(master, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) expect(request).toMatchObject({ width: 800, height: 800 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) @@ -242,15 +244,15 @@ describe('local request-image cache', () => { it('shares one request transform between concurrent callers without sharing cancellation', async () => { const attachments = await store() - const master = (await attachments.saveImage({ + const attachment = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'shared.png', - })).ref + }) const run = vi.spyOn(CompressionLimiter.prototype, 'run') const controller = new AbortController() const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } - const cancelled = attachments.readImageRequest(master, policy, controller.signal) - const completed = attachments.readImageRequest(master, policy) + const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) + const completed = attachments.readImageRequest(attachment, policy) const reason = new Error('cancel one waiter') controller.abort(reason) @@ -262,9 +264,9 @@ describe('local request-image cache', () => { it('aborts the underlying request transform after its only waiter cancels', async () => { const attachments = await store() - const master = (await attachments.saveImage({ + const attachment = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'cancelled.png', - })).ref + }) let readSignal: AbortSignal | undefined const read = vi.spyOn(attachments, 'readImage').mockImplementation((_ref, signal) => { readSignal = signal @@ -276,7 +278,7 @@ describe('local request-image cache', () => { }) const controller = new AbortController() const request = attachments.readImageRequest( - master, + attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 }, controller.signal, ) @@ -293,9 +295,9 @@ describe('local request-image cache', () => { it('normalizes a non-Error cancellation and replaces an aborted shared transform', async () => { const attachments = await store() - const master = (await attachments.saveImage({ + const attachment = await attachments.saveImage({ data: await image(2048, 1024), mediaType: 'image/png', name: 'replace.png', - })).ref + }) const actualRead = attachments.readImage.bind(attachments) let calls = 0 vi.spyOn(attachments, 'readImage').mockImplementation((ref, signal) => { @@ -311,13 +313,13 @@ describe('local request-image cache', () => { }) const controller = new AbortController() const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } - const cancelled = attachments.readImageRequest(master, policy, controller.signal) + const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) await vi.waitFor(() => { expect(calls).toBe(1) }) controller.abort('cancelled') - const replacement = attachments.readImageRequest(master, policy) + const replacement = attachments.readImageRequest(attachment, policy) await expect(cancelled).rejects.toMatchObject({ message: 'Attachment request cancelled with a non-Error reason.', diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index f0c127c174..ad29f856ec 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -7,7 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { MasterImagePolicy } from '../src/canonical.ts' +import type { NormalizationPolicy } from '../src/normalization.ts' import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts' const fsControl = vi.hoisted(() => ({ @@ -39,7 +39,7 @@ const PNG = Uint8Array.from(Buffer.from( 'base64', )) -const POLICY: MasterImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, @@ -107,7 +107,7 @@ describe('local attachment store', () => { it('creates and persists a missing nested home directory against the filesystem root', async () => { const storageRoot = join(await root(), 'home', 'attachments', 'v1') - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) @@ -121,7 +121,7 @@ describe('local attachment store', () => { const sha256 = createHash('sha256').update(PNG).digest('hex') const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) - expect(first.ref).toEqual({ + expect(first).toEqual({ attachmentId: `sha256:${sha256}`, mediaType: 'image/png', bytes: PNG.byteLength, @@ -129,17 +129,16 @@ describe('local attachment store', () => { height: 1, name: 'pixel.png', }) - expect(first.source).toEqual({ mediaType: 'image/png', bytes: PNG.byteLength, width: 1, height: 1 }) - expect(second.ref.attachmentId).toBe(first.ref.attachmentId) + expect(second.attachmentId).toBe(first.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) if (process.platform !== 'win32') { expect((await stat(object)).mode & 0o777).toBe(0o600) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) } - await expect(readImageFile(storageRoot, first.ref)).resolves.toEqual({ ref: first.ref, data: PNG }) + await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) - it('stores the image master of an oversized source and reads it back verified', async () => { + it('stores the normalized image of an oversized source and reads it back verified', async () => { const storageRoot = await root() const oversized = new Uint8Array(await sharp({ create: { width: 4, height: 4, channels: 3, background: { r: 9, g: 9, b: 9 } }, @@ -149,24 +148,29 @@ describe('local attachment store', () => { data: oversized, mediaType: 'image/png', name: 'big.png', }, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 }) - expect(saved.source).toEqual({ mediaType: 'image/png', bytes: oversized.byteLength, width: 4, height: 4 }) - expect(saved.ref).toMatchObject({ mediaType: 'image/png', width: 2, height: 2, name: 'big.png' }) - expect(saved.ref.bytes).not.toBe(oversized.byteLength) - const read = await readImageFile(storageRoot, saved.ref) - expect(read.data.byteLength).toBe(saved.ref.bytes) - expect(String(saved.ref.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) + expect(saved).toMatchObject({ + mediaType: 'image/png', + width: 2, + height: 2, + name: 'big.png', + originalDimensions: { width: 4, height: 4 }, + }) + expect(saved.bytes).not.toBe(oversized.byteLength) + const read = await readImageFile(storageRoot, saved) + expect(read.data.byteLength).toBe(saved.bytes) + expect(String(saved.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) }) it('keeps admitted history readable after deployment limits become stricter', async () => { const storageRoot = await root() - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) it('forwards read cancellation to the filesystem and preserves its reason', async () => { const storageRoot = await root() - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const controller = new AbortController() fsControl.readSignals.length = 0 @@ -205,12 +209,12 @@ describe('local attachment store', () => { const unnamed = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '\u0000', }, LIMITS, POLICY) - expect(unnamed.ref).not.toHaveProperty('name') + expect(unnamed).not.toHaveProperty('name') }) it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { const storageRoot = await root() - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const sha256 = String(ref.attachmentId).slice('sha256:'.length) const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await chmod(object, 0o600) @@ -242,7 +246,7 @@ describe('local attachment store', () => { .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) await writeFile(target, PNG) - const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) }) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index bbccf584c6..e27f25e933 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.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 packages/attachment/attachment/README.md -README.md: 66ce5f308cfa1ce6a028dbd248ceef1fdcc31a7c -README.zh.md: 4470956987330a451e3717d419a111def98dd6cb +README.md: 3ad568c7308f1ab85cb4af3fcc2afd3cba9a611a +README.zh.md: fadbb1c5bbf097c599da651055d63a1ed64cd579 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 66ce5f308c..3ad568c730 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent master image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every validated master once before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: the returned `ref` describes the stored master while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and orientation-applied dimensions. `readImage` verifies that master against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the master id, transform version, pixel and byte budgets, and encoder settings; `readImageRequests` preserves ordered results while implementations apply their own bounded concurrency. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 4470956987..fadbb1c5bb 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的图片主版本,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前为全部成员各准备一次经过验证的主版本,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:返回的 `ref` 描述实际存储的主版本,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和应用方向后的尺寸。`readImage` 根据已记录的元数据校验该主版本。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖主版本 ID、变换策略版本、像素和字节预算及编码参数;`readImageRequests` 保持结果顺序,并由实现施加自己的有界并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 85401fad23..8b54926efa 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -8,7 +8,6 @@ import type { ImageRequestPolicy, RequestImageAttachment, SaveImageAttachment, - SavedImageAttachment, StoredImageAttachment, } from './types.ts' @@ -25,8 +24,6 @@ export type { ImageMediaType, RequestImageAttachment, SaveImageAttachment, - SavedImageAttachment, - SourceImageInfo, StoredImageAttachment, } from './types.ts' @@ -80,40 +77,39 @@ export abstract class AttachmentStore extends Service { /** * Validate and durably commit one ordered image batch. * @param inputs - encoded images in owning-message order. - * @returns durable master references in the same order after every member succeeds. + * @returns durable normalized attachment references in the same order after every member succeeds. */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise { this.validateImageBatch(inputs) for (const input of inputs) await this.validateImage(input) const refs: ImageAttachmentRef[] = [] - for (const input of inputs) refs.push((await this.saveImage(input)).ref) + for (const input of inputs) refs.push(await this.saveImage(input)) return refs } /** * Validate and durably commit one image before its owning session event is appended. - * Implementations may store a prepared master version of the submitted raster; - * the returned reference always describes the stored bytes, while `source` - * preserves the submitted raster's intrinsic facts for callers that report - * or map coordinates against the original. + * The returned reference describes the persisted normalized image. When + * normalization reduces the raster, its `originalDimensions` records the + * orientation-applied input dimensions. * @param input - encoded bytes, declared media type, and optional display name. - * @returns the durable content-addressed reference beside the submitted source facts. + * @returns the durable content-addressed normalized image reference. */ - abstract saveImage(input: SaveImageAttachment): Promise + abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and master reference. + * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Generate or read one deterministic model-request version from the stored master image. - * @param ref - durable provider-independent master reference. + * Generate or read one deterministic model-request version from the stored normalized image. + * @param ref - durable provider-independent normalized attachment reference. * @param policy - exact route pixel and encoded-byte budget. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. @@ -132,24 +128,6 @@ export abstract class AttachmentStore extends Service { )) } - /** - * Generate or read an ordered batch of deterministic model-request versions. - * Implementations may use their own bounded transform concurrency while preserving input order. - * @param refs - durable provider-independent master references in request order. - * @param policy - exact route pixel and encoded-byte budget shared by the batch. - * @param signal - optional cancellation. - * @returns request versions in the same order as `refs`. - */ - async readImageRequests( - refs: readonly ImageAttachmentRef[], - policy: ImageRequestPolicy, - signal?: AbortSignal, - ): Promise { - const versions: RequestImageAttachment[] = [] - for (const ref of refs) versions.push(await this.readImageRequest(ref, policy, signal)) - return versions - } - } export default AttachmentStore diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 04f7362d38..e23a7a7d4c 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -7,7 +7,7 @@ export type { AttachmentId } from './brand.ts' /** Raster image formats accepted by the version-one attachment path. */ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' -/** Durable, serializable metadata for one immutable image object. */ +/** Durable, serializable reference to one immutable normalized image. */ export interface ImageAttachmentRef { /** Opaque storage identifier; never a filesystem path or bearer URL. */ attachmentId: AttachmentId @@ -21,10 +21,14 @@ export interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string - /** Perceived source width before master-version downscaling; present only when it differs from {@link width}. */ - sourceWidth?: number - /** Perceived source height before master-version downscaling; present only when it differs from {@link height}. */ - sourceHeight?: number + /** + * Input dimensions after applying EXIF orientation and before normalization + * scaling. Present only when normalization reduced the image. + */ + originalDimensions?: { + width: number + height: number + } } /** Deployment-resolved limits used by upload admission and request buffering. */ @@ -71,12 +75,12 @@ export interface ImageRequestPolicy { maxBytes: number } -/** Cached request version derived from one provider-independent master attachment. */ +/** Cached request version derived from one provider-independent normalized attachment. */ export interface RequestImageAttachment { - /** Cache and upload-index key over the master id, policy, and fixed encoder parameters. */ + /** Cache and upload-index key over the attachment id, policy, and fixed encoder parameters. */ variantId: ImageVariantId - /** Durable master reference from which this request version was derived. */ - master: ImageAttachmentRef + /** Durable normalized attachment from which this request version was derived. */ + attachment: ImageAttachmentRef /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -90,23 +94,3 @@ export interface RequestImageAttachment { /** Whether the encoded request version retains an alpha channel. */ hasAlpha: boolean } - -/** Intrinsic facts of the submitted source raster, before master-version preparation. */ -export interface SourceImageInfo { - /** Media type verified from the submitted bytes. */ - mediaType: ImageMediaType - /** Exact submitted encoded byte length. */ - bytes: number - /** Perceived source width in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */ - width: number - /** Perceived source height in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */ - height: number -} - -/** Commit result pairing the durable reference with the submitted source raster it was derived from. */ -export interface SavedImageAttachment { - /** Durable reference describing the stored bytes. */ - ref: ImageAttachmentRef - /** Submitted source raster facts; equals the `ref` fields when the store kept the submitted bytes. */ - source: SourceImageInfo -} diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 589a4322d9..be784f0276 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -10,7 +10,6 @@ import AttachmentStore, { type ImageRequestPolicy, type RequestImageAttachment, type SaveImageAttachment, - type SavedImageAttachment, type StoredImageAttachment, } from '../src/index.ts' @@ -35,20 +34,17 @@ class RecordingStore extends AttachmentStore { if (value === this.rejectValidationAt) throw new Error(`invalid:${value}`) } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { const value = input.data[0] ?? 0 this.calls.push(`save:${value}`) if (value === this.rejectSaveAt) throw new Error(`write:${value}`) return { - ref: { - attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`), - mediaType: input.mediaType, - bytes: input.data.byteLength, - width: 1, - height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1 }, + attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`), + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, } } @@ -63,7 +59,7 @@ class RecordingStore extends AttachmentStore { this.calls.push(`request:${ref.name}`) return Promise.resolve({ variantId: ImageVariantId(`sha256:${String(ref.bytes).padStart(64, '0')}`), - master: ref, + attachment: ref, data: Uint8Array.of(ref.bytes), mediaType: ref.mediaType, bytes: 1, @@ -83,7 +79,7 @@ class UnsupportedProjectionStore extends AttachmentStore { return Promise.resolve() } - saveImage(): Promise { + saveImage(): Promise { throw new Error('not used') } @@ -139,21 +135,10 @@ describe('AttachmentStore.saveImages', () => { }) }) -describe('AttachmentStore.readImageRequests', () => { - it('uses the default serial projection and preserves input order', async () => { - const store = new RecordingStore(new Context()) - const refs = await store.saveImages([image(1), image(2)]) - store.calls.length = 0 - - const versions = await store.readImageRequests(refs, { maxPixels: 1, maxBytes: 1 }) - - expect(store.calls).toEqual(['request:1.png', 'request:2.png']) - expect(versions.map(version => version.master.name)).toEqual(['1.png', '2.png']) - }) - +describe('AttachmentStore.readImageRequest', () => { it('reports unsupported request projection while preserving cancellation', async () => { const store = new UnsupportedProjectionStore(new Context()) - const ref = (await new RecordingStore(new Context()).saveImage(image(1))).ref + const ref = await new RecordingStore(new Context()).saveImage(image(1)) await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) const controller = new AbortController() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0dbe8f0535..464a3d8f5c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -440,33 +440,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async saveImages(inputs: readonly SaveImageAttachment[]): Promise', description: 'Validate and durably commit one ordered image batch.', parameters: [{ name: 'inputs', description: 'encoded images in owning-message order.' }], - returns: 'durable master references in the same order after every member succeeds.', + returns: 'durable normalized attachment references in the same order after every member succeeds.', }, { - signature: 'abstract saveImage(input: SaveImageAttachment): Promise', - description: 'Validate and durably commit one image before its owning session event is appended. Implementations may store a prepared master version of the submitted raster; the returned reference always describes the stored bytes, while `source` preserves the submitted raster\'s intrinsic facts for callers that report or map coordinates against the original.', + signature: 'abstract saveImage(input: SaveImageAttachment): Promise', + description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.', parameters: [{ name: 'input', description: 'encoded bytes, declared media type, and optional display name.' }], - returns: 'the durable content-addressed reference beside the submitted source facts.', + returns: 'the durable content-addressed normalized image reference.', }, { signature: 'abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise', description: 'Read one image and verify that bytes still match the recorded reference.', parameters: [{ name: 'ref', description: 'durable reference from the session log.' }, { name: 'signal', description: 'optional cancellation for backend read and verification work.' }], - returns: 'the verified bytes and master reference.', + returns: 'the verified bytes and normalized attachment reference.', throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', - description: 'Generate or read one deterministic model-request version from the stored master image.', - parameters: [{ name: 'ref', description: 'durable provider-independent master reference.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], + description: 'Generate or read one deterministic model-request version from the stored normalized image.', + parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', }, - { - signature: 'async readImageRequests( refs: readonly ImageAttachmentRef[], policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', - description: 'Generate or read an ordered batch of deterministic model-request versions. Implementations may use their own bounded transform concurrency while preserving input order.', - parameters: [{ name: 'refs', description: 'durable provider-independent master references in request order.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget shared by the batch.' }, { name: 'signal', description: 'optional cancellation.' }], - returns: 'request versions in the same order as `refs`.', - }, ], }, { @@ -3462,7 +3456,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageAttachmentRef', - declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n sourceWidth?: number;\n sourceHeight?: number;\n}', + declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n originalDimensions?: {\n width: number;\n height: number;\n };\n}', }, { name: 'ImageBlock', @@ -3942,7 +3936,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestImageAttachment', - declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n master: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', }, { name: 'RequestRunOutcome', @@ -4028,10 +4022,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, - { - name: 'SavedImageAttachment', - declaration: 'export interface SavedImageAttachment {\n ref: ImageAttachmentRef;\n source: SourceImageInfo;\n}', - }, { name: 'SaveImageAttachment', declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', @@ -4436,10 +4426,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillViewOptions', declaration: 'export interface SkillViewOptions extends SkillLookupOptions {\n readonly scope?: ScopeKey | undefined;\n}', }, - { - name: 'SourceImageInfo', - declaration: 'export interface SourceImageInfo {\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n}', - }, { name: 'SpawnTeammateRequest', declaration: 'export interface SpawnTeammateRequest {\n readonly name: string;\n readonly description: string;\n readonly prompt: ContentBlock[];\n readonly context: \'fresh\' | \'fork\';\n readonly provider: string;\n readonly signal: AbortSignal;\n}', diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 6c590d54b4..3ef67e88c0 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.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 packages/fs/tool-fs/README.md -README.md: ab01840f122d6e0df2782b86840432914b27ebd0 -README.zh.md: ef738a3715b6db45d386d56ba2a776960dd341c1 +README.md: 763cb831233da5b1f14c73e353920e9d6a87ced9 +README.zh.md: aa55de59452e7779ef278748abac17837800ba02 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index ab01840f12..763cb83123 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -38,7 +38,7 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. -Structured successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. The image source fields appear only when master preparation downscaled the submitted raster. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`; execution-local structured values are not added to `tool/result`, while image renderers emit the durable image blocks that the result logs. +Structured successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, originalDimensions?: { width, height } } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. `originalDimensions` appears only when normalization downscaled the submitted raster and records its orientation-applied input size. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`; execution-local structured values are not added to `tool/result`, while image renderers emit the durable image blocks that the result logs. ## The tool is the executor; policy is an event gate @@ -127,7 +127,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, master dimensions, and byte size, followed by the image itself as a native image block. The result is logged with its durable reference before the next model request. +A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, normalized dimensions, and byte size, followed by the image itself as a native image block. The result is logged with its durable reference before the next model request. #### Token effect @@ -155,7 +155,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`. A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`. A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index ef738a3715..aa55de5945 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -38,7 +38,7 @@ await ctx.plugin(ToolFs) // this package — re 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -结构化成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, sourceWidth?, sourceHeight? } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。图片 source 字段只在主版本准备缩小了提交光栅时出现。原生渲染器会保留下方带行号的读取结果和变更确认。`write` 和 `edit` 从这些值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;仅用于执行的结构化值不会添加到 `tool/result`,图片渲染器则会发出由结果记录的持久图片块。 +结构化成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name?, originalDimensions?: { width, height } } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。`originalDimensions` 只在规范化过程缩小提交光栅时出现,并记录应用方向后的输入尺寸。原生渲染器会保留下方带行号的读取结果和变更确认。`write` 和 `edit` 从这些值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;仅用于执行的结构化值不会添加到 `tool/result`,图片渲染器则会发出由结果记录的持久图片块。 ## 工具就是执行器;策略是事件门禁 @@ -127,7 +127,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -成功的 `read_image` 返回 ``、`image` 和写明媒体类型、主版本尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。结果会随持久引用写入会话日志,然后才进入下一次模型请求。 +成功的 `read_image` 返回 ``、`image` 和写明媒体类型、规范化尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。结果会随持久引用写入会话日志,然后才进入下一次模型请求。 #### Token 影响 @@ -155,7 +155,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,不会重复陈旧恢复指令;write 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index bbf49d568c..b1cbad9bb9 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -38,8 +38,14 @@ const IMAGE_VALUE_SCHEMA = { width: { type: 'integer', required: true }, height: { type: 'integer', required: true }, name: { type: 'string' }, - sourceWidth: { type: 'integer' }, - sourceHeight: { type: 'integer' }, + originalDimensions: { + type: 'object', + additionalProperties: false, + properties: { + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + }, + }, }, } as const @@ -53,10 +59,11 @@ export interface ImageReadValue { width: number height: number name?: string - /** Intrinsic width of the file on disk; present only when storage downscaled it. */ - sourceWidth?: number - /** Intrinsic height of the file on disk; present only when storage downscaled it. */ - sourceHeight?: number + /** Orientation-applied file dimensions before normalization; present only when storage reduced it. */ + originalDimensions?: { + width: number + height: number + } } } @@ -105,8 +112,9 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme width: image.width, height: image.height, ...image.name === undefined ? {} : { name: image.name }, - ...image.sourceWidth === undefined ? {} : { sourceWidth: image.sourceWidth }, - ...image.sourceHeight === undefined ? {} : { sourceHeight: image.sourceHeight }, + ...image.originalDimensions === undefined ? {} : { + originalDimensions: { ...image.originalDimensions }, + }, } } @@ -120,15 +128,15 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme */ export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string { let scaled = '' - if (image.sourceWidth !== undefined && image.sourceHeight !== undefined) { + if (image.originalDimensions !== undefined) { // Integer rounding can give the two axes slightly different ratios, so the // advice names one multiplier only when both round to the same value. - const x = (image.sourceWidth / image.width).toFixed(2) - const y = (image.sourceHeight / image.height).toFixed(2) + const x = (image.originalDimensions.width / image.width).toFixed(2) + const y = (image.originalDimensions.height / image.height).toFixed(2) const advice = x === y ? `multiply coordinates by ${x}` : `multiply x coordinates by ${x} and y coordinates by ${y}` - scaled = ` (downscaled from ${image.sourceWidth}x${image.sourceHeight} px; ${advice} to locate features in the original file)` + scaled = ` (downscaled from ${image.originalDimensions.width}x${image.originalDimensions.height} px; ${advice} to locate features in the original file)` } return `${displayPath} image @@ -208,11 +216,8 @@ export function applyReadImageTool(ctx: Context): void { // Persist before returning: the image block must reference a durably // committed object by the time the tool/result event is appended. let ref: ImageAttachmentRef - let source: { width: number; height: number } try { - const saved = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) - ref = saved.ref - source = saved.source + ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) } catch (error: unknown) { if (!(error instanceof AttachmentError)) throw error // Dimension refusals stay recoverable tool errors: an oversized image @@ -238,7 +243,7 @@ export function applyReadImageTool(ctx: Context): void { } if (error.code === 'ATTACHMENT_WRITE_FAILED' && /16-bit PNG/iu.test(error.message)) { throw new Error( - `cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, + `cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, { cause: error }, ) } @@ -250,7 +255,6 @@ export function applyReadImageTool(ctx: Context): void { ) } ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) - const downscaled = source.width !== ref.width || source.height !== ref.height const value: ImageReadValue = { path: target.displayPath, image: { @@ -260,7 +264,9 @@ export function applyReadImageTool(ctx: Context): void { width: ref.width, height: ref.height, ...ref.name === undefined ? {} : { name: ref.name }, - ...downscaled ? { sourceWidth: source.width, sourceHeight: source.height } : {}, + ...ref.originalDimensions === undefined ? {} : { + originalDimensions: { ...ref.originalDimensions }, + }, }, } return value diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 16e07d93a8..6b33b3dcb3 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -21,7 +21,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import { AttachmentError, AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { applyReadImageTool, @@ -170,8 +170,8 @@ describe('imageRefFromValue', () => { const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } expect(imageRefFromValue(base)).toEqual(base) expect(imageRefFromValue({ ...base, name: 'a.png' })).toEqual({ ...base, name: 'a.png' }) - expect(imageRefFromValue({ ...base, sourceWidth: 4, sourceHeight: 2 })) - .toEqual({ ...base, sourceWidth: 4, sourceHeight: 2 }) + expect(imageRefFromValue({ ...base, originalDimensions: { width: 4, height: 2 } })) + .toEqual({ ...base, originalDimensions: { width: 4, height: 2 } }) }) }) @@ -347,7 +347,7 @@ describe('argument and service preconditions', () => { throw new Error('unreachable: admission refuses before validation') } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { throw new Error('unreachable: admission refuses before save') } @@ -424,7 +424,7 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(_input: SaveImageAttachment): Promise { + async saveImage(_input: SaveImageAttachment): Promise { throw FailingStore.failure } @@ -442,15 +442,15 @@ describe('image admission failures', () => { expect(text(storageFault)).toContain('Unable to persist image attachment.') FailingStore.failure = new AttachmentError( - 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.', + 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', 'ATTACHMENT_WRITE_FAILED', ) const sixteenBit = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(text(sixteenBit)).toContain( - `cannot read "${join(dir, 'red.png')}": the 16-bit PNG could not be converted to the canonical 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, + `cannot read "${join(dir, 'red.png')}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, ) - FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE') + FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') const overBudget = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) expect(overBudget.isError).toBe(true) expect(text(overBudget)).toContain('cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy') @@ -492,11 +492,8 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(input: SaveImageAttachment): Promise { - return { - ref: { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 }, - source: { mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 }, - } + async saveImage(input: SaveImageAttachment): Promise { + return { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 } } readImage(_ref: ImageAttachmentRef): Promise { @@ -513,7 +510,7 @@ describe('image admission failures', () => { }) it('names the on-disk dimensions and coordinate multiplier when storage downscales', async () => { - /** Store whose image master halves the source on both sides. */ + /** Store whose normalized image halves the input on both sides. */ class DownscalingStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = Object.freeze({ maxImageBytes: 1024, @@ -528,10 +525,14 @@ describe('image admission failures', () => { return Promise.resolve() } - async saveImage(input: SaveImageAttachment): Promise { + async saveImage(input: SaveImageAttachment): Promise { return { - ref: { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: 7, width: 2, height: 1 }, - source: { mediaType: input.mediaType, bytes: input.data.length, width: 4, height: 2 }, + attachmentId: AttachmentId('sha256:feed'), + mediaType: input.mediaType, + bytes: 7, + width: 2, + height: 1, + originalDimensions: { width: 4, height: 2 }, } } @@ -549,7 +550,8 @@ describe('image admission failures', () => { it('names per-axis multipliers when integer rounding makes the ratios differ', () => { const envelope = formatImageReadOutput('/img/photo.jpg', { - attachmentId: 'sha256:feed', mediaType: 'image/jpeg', bytes: 9, width: 2, height: 1, sourceWidth: 5, sourceHeight: 2, + attachmentId: 'sha256:feed', mediaType: 'image/jpeg', bytes: 9, width: 2, height: 1, + originalDimensions: { width: 5, height: 2 }, }) expect(envelope).toContain('downscaled from 5x2 px; multiply x coordinates by 2.50 and y coordinates by 2.00 to locate features in the original file') }) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index aa163784df..2127844646 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -243,11 +243,8 @@ describe('/goal image attachments', () => { const saveImage = (input: { mediaType: string; name?: string }) => { saved += 1 return Promise.resolve({ - ref: { - attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, }) } test.ctx.provide('attachments', { @@ -259,7 +256,7 @@ describe('/goal image attachments', () => { saveImage, async saveImages(inputs: readonly { mediaType: string; name?: string }[]) { const refs = [] - for (const input of inputs) refs.push((await saveImage(input)).ref) + for (const input of inputs) refs.push(await saveImage(input)) return refs }, }) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 99f99c3432..1317220ef3 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -134,15 +134,12 @@ describe('Web session model selection', () => { const { ctx, agent, sessionId } = await harness() const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve()) const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({ - ref: { - attachmentId: `att-${String(input.data[0])}`, - mediaType: input.mediaType, - bytes: input.data.byteLength, - width: 1, - height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1 }, + attachmentId: `att-${String(input.data[0])}`, + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, })) const attachments = { imageLimits: { diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 85806a1d36..a95ee024dc 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -479,11 +479,8 @@ describe('image attachments', () => { saveImage: vi.fn((input: { mediaType: string; name?: string }) => { saved += 1 return Promise.resolve({ - ref: { - attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, - ...input.name === undefined ? {} : { name: input.name }, - }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + ...input.name === undefined ? {} : { name: input.name }, }) }), validateImageBatch(inputs: readonly unknown[]) { @@ -595,8 +592,7 @@ describe('image attachments', () => { store.saveImage.mockImplementationOnce((input: { mediaType: string }) => { controller.abort('operator cancelled during admission') return Promise.resolve({ - ref: { attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1, }) }) ctx.provide('attachments', store) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index bea18ff3ac..c4db847155 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: bb7f6a520701134cd43ff6223ef4efbf82d02eb4 -README.zh.md: 934c189232711655aa785a7497f5bb6dff1cbb46 +README.md: d17d520c2444d8a0195d997f4df4ff5e0f05befd +README.zh.md: cc823897894102df0dc1da17478eee6ba7ebd21d diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index bb7f6a5207..d17d520c24 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -49,11 +49,11 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 master becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. -`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained masters are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. +`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. -Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the master attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. +Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the normalized attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. Concurrent resolution of one scoped `variantId` shares one Files upload with waiter-local cancellation. One quota upload failure first paginates and collects the configured number of oldest `dsh-` files, then deletes that set before one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 934c189232..cc82389789 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -49,11 +49,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 主版本会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 -`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的主版本。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 +`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 -上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖主附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 同一作用域和 `variantId` 的并发解析共享一次 Files 上传,每个等待方可以单独取消。一次上传配额错误会先分页收集配置数量的最旧 `dsh-` 文件,再删除这些文件并重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 30817c738e..9c4756f3d3 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -200,7 +200,9 @@ async function prepareRequestImages( for (const message of options.messages) collectImageRefs(message.content, refs) const policy = resolveRequestImagePolicy(model) const orderedRefs = [...refs.values()] - const projected = await attachments.readImageRequests(orderedRefs, policy, signal) + const projected = await Promise.all(orderedRefs.map( + ref => attachments.readImageRequest(ref, policy, signal), + )) return new Map(orderedRefs.map((ref, index) => ( [ref.attachmentId, projected[index] as RequestImageAttachment] ))) @@ -250,7 +252,7 @@ function normalizedImageFacts( file: { version: RequestImageAttachment; location: ImageWireLocation }, ): string { const version = file.version - const name = version.master.name ?? version.master.attachmentId + const name = version.attachment.name ?? version.attachment.attachmentId const colour = version.hasAlpha ? 'sRGBA' : 'sRGB' return `"${name}" at message ${file.location.message}, image ${file.location.image} ` + `(${version.mediaType}, 8-bit ${colour}, ${version.width}x${version.height})` diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts index 0757b42db2..fde86fa44e 100644 --- a/packages/llm/llm-deepseek/src/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -102,9 +102,9 @@ function extension(mediaType: RequestImageAttachment['mediaType']): 'png' | 'jpe } function filename(version: RequestImageAttachment): string { - const master = String(version.master.attachmentId).slice('sha256:'.length, 'sha256:'.length + 16) + const attachment = String(version.attachment.attachmentId).slice('sha256:'.length, 'sha256:'.length + 16) const variant = String(version.variantId).slice('sha256:'.length, 'sha256:'.length + 8) - return `${OWNED_FILE_PREFIX}${master}-${variant}.${extension(version.mediaType)}` + return `${OWNED_FILE_PREFIX}${attachment}-${variant}.${extension(version.mediaType)}` } /** User-scoped durable file-id reuse for the DeepSeek route. */ @@ -204,7 +204,7 @@ export class DeepSeekFileStore { } return { scope, - masterAttachmentId: version.master.attachmentId, + attachmentId: version.attachment.attachmentId, variantId: version.variantId, fileId: remote.id, bytes: remote.bytes, diff --git a/packages/llm/llm-deepseek/src/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts index 297e1021c1..d442bb55fe 100644 --- a/packages/llm/llm-deepseek/src/upload-index.ts +++ b/packages/llm/llm-deepseek/src/upload-index.ts @@ -13,8 +13,8 @@ import type { DeepSeekFileId as DeepSeekFileIdType, DeepSeekFileScope as DeepSee /** One durable remote upload mapping. Unix times are milliseconds. */ export interface DeepSeekUploadRecord { scope: DeepSeekFileScopeType - /** Provider-independent master attachment from which the uploaded request version was derived. */ - masterAttachmentId: AttachmentId + /** Provider-independent normalized attachment from which the uploaded request version was derived. */ + attachmentId: AttachmentId /** Complete request transformation identity, including route budgets and encoder parameters. */ variantId: ImageVariantIdType fileId: DeepSeekFileIdType @@ -24,7 +24,7 @@ export interface DeepSeekUploadRecord { } interface StoredIndex { - formatVersion: 2 + formatVersion: 3 records: DeepSeekUploadRecord[] } @@ -61,7 +61,7 @@ function parseRecord(value: unknown): DeepSeekUploadRecord { } const record = value as Record if (typeof record.scope !== 'string' || !/^[0-9a-f]{64}$/u.test(record.scope) - || typeof record.masterAttachmentId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.masterAttachmentId) + || typeof record.attachmentId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.attachmentId) || typeof record.variantId !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(record.variantId) || typeof record.fileId !== 'string' || record.fileId.length === 0 || !Number.isSafeInteger(record.bytes) || (record.bytes as number) < 0 @@ -71,7 +71,7 @@ function parseRecord(value: unknown): DeepSeekUploadRecord { } return { scope: DeepSeekFileScope(record.scope), - masterAttachmentId: record.masterAttachmentId as AttachmentId, + attachmentId: record.attachmentId as AttachmentId, variantId: ImageVariantId(record.variantId), fileId: DeepSeekFileId(record.fileId), bytes: record.bytes as number, @@ -91,7 +91,7 @@ function parseIndex(text: string): StoredIndex { throw new InvalidUploadIndexError('llm-deepseek: upload index is not an object') } const index = value as { formatVersion?: unknown; records?: unknown } - if (index.formatVersion !== 2 || !Array.isArray(index.records)) { + if (index.formatVersion !== 3 || !Array.isArray(index.records)) { throw new InvalidUploadIndexError('llm-deepseek: unsupported upload index format') } const records = index.records.map(parseRecord) @@ -101,7 +101,7 @@ function parseIndex(text: string): StoredIndex { if (keys.has(key)) throw new InvalidUploadIndexError('llm-deepseek: upload index contains duplicate mappings') keys.add(key) } - return { formatVersion: 2, records } + return { formatVersion: 3, records } } function reusable(record: DeepSeekUploadRecord, now: number, refreshMarginMs: number): boolean { @@ -114,9 +114,9 @@ export class DeepSeekUploadIndex { readonly path: string /** - * @param path - explicit test path; omission uses `DSH_HOME/llm-deepseek/files-v2.json`. + * @param path - explicit test path; omission uses `DSH_HOME/llm-deepseek/files-v3.json`. */ - constructor(path = join(resolveDshHome(), 'llm-deepseek', 'files-v2.json')) { + constructor(path = join(resolveDshHome(), 'llm-deepseek', 'files-v3.json')) { this.path = path } @@ -125,7 +125,7 @@ export class DeepSeekUploadIndex { return parseIndex(await readFile(this.path, 'utf8')) } catch (error: unknown) { if (absent(error) || error instanceof InvalidUploadIndexError) { - return { formatVersion: 2, records: [] } + return { formatVersion: 3, records: [] } } throw error } @@ -184,7 +184,7 @@ export class DeepSeekUploadIndex { && !(record.scope === candidate.scope && record.variantId === candidate.variantId) )) records.push(candidate) - await this.save({ formatVersion: 2, records }) + await this.save({ formatVersion: 3, records }) return { record: candidate, accepted: true } }) } @@ -206,7 +206,7 @@ export class DeepSeekUploadIndex { const records = index.records.filter(record => !( record.scope === scope && record.variantId === variantId && record.fileId === fileId )) - if (records.length !== index.records.length) await this.save({ formatVersion: 2, records }) + if (records.length !== index.records.length) await this.save({ formatVersion: 3, records }) }) } @@ -219,7 +219,7 @@ export class DeepSeekUploadIndex { await withFileLock(this.path, async () => { const index = await this.load() const records = index.records.filter(record => record.scope !== scope) - if (records.length !== index.records.length) await this.save({ formatVersion: 2, records }) + if (records.length !== index.records.length) await this.save({ formatVersion: 3, records }) }) } } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 3858f214a0..ee3bea8435 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -13,7 +13,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -58,7 +57,7 @@ class E2eAttachmentStore extends AttachmentStore { } readonly version: RequestImageAttachment = { variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`), - master: this.ref, + attachment: this.ref, data: TEST_PNG, mediaType: 'image/png', bytes: TEST_PNG.byteLength, @@ -73,16 +72,8 @@ class E2eAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve({ - ref: this.ref, - source: { - mediaType: this.ref.mediaType, - bytes: this.ref.bytes, - width: this.ref.width, - height: this.ref.height, - }, - }) + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(this.ref) } readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 08b3706758..baac1ee39e 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -77,7 +77,7 @@ const imageRef: ImageAttachmentRef = { function requestImage(ref = imageRef): RequestImageAttachment { return { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: ref, + attachment: ref, data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', bytes: 3, @@ -94,18 +94,11 @@ function attachmentStoreOf( ): { store: AttachmentStore readImageRequest: ReturnType> - readImageRequests: ReturnType } { const readImageRequest = vi.fn(project) - const readImageRequests = vi.fn(async ( - refs: readonly ImageAttachmentRef[], - policy: unknown, - signal?: AbortSignal, - ) => Promise.all(refs.map(ref => readImageRequest(ref, policy, signal)))) return { - store: { readImageRequest, readImageRequests } as unknown as AttachmentStore, + store: { readImageRequest } as unknown as AttachmentStore, readImageRequest, - readImageRequests, } } @@ -233,8 +226,8 @@ describe('DeepSeekAdapter against a mock server', () => { })], })) - expect(attachmentMocks.readImageRequests).toHaveBeenCalledWith( - [recent], + expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( + recent, { maxPixels: 640_000, maxBytes: 1024 * 1024 }, expect.any(AbortSignal), ) @@ -283,15 +276,15 @@ describe('DeepSeekAdapter against a mock server', () => { await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [nested] })) await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [nested] })) - expect(attachmentMocks.readImageRequests).toHaveBeenNthCalledWith( + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 1, - [imageRef], + imageRef, { maxPixels: 512 * 512, maxBytes: 512_000 }, expect.any(AbortSignal), ) - expect(attachmentMocks.readImageRequests).toHaveBeenNthCalledWith( + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 2, - [imageRef], + imageRef, { maxPixels: 320_000, maxBytes: 1024 * 1024 }, expect.any(AbortSignal), ) @@ -395,7 +388,7 @@ describe('DeepSeekAdapter against a mock server', () => { return Promise.resolve({ ...requestImage(ref), variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), - master: first ? { ...ref, name: 'diagram.png' } : ref, + attachment: first ? { ...ref, name: 'diagram.png' } : ref, hasAlpha: false, }) }).store diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index c0a2e29750..4617ebdfed 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -10,7 +10,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -46,11 +45,8 @@ class StaticAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve({ - ref: IMAGE_REF, - source: { mediaType: IMAGE_REF.mediaType, bytes: IMAGE_REF.bytes, width: IMAGE_REF.width, height: IMAGE_REF.height }, - }) + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(IMAGE_REF) } readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { @@ -64,7 +60,7 @@ class StaticAttachmentStore extends AttachmentStore { ): Promise { return Promise.resolve({ variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: ref, + attachment: ref, data: Uint8Array.of(1, 2, 3), mediaType: ref.mediaType, bytes: 3, diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index 069ff47c9d..d6d154033b 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -17,7 +17,7 @@ const REF: ImageAttachmentRef = { } const VERSION: RequestImageAttachment = { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: REF, + attachment: REF, data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', bytes: 3, @@ -328,7 +328,7 @@ describe('DeepSeekFileStore', () => { accepted: false, record: { scope: deepSeekFileScope(CONNECTION.baseURL, CONNECTION.apiKey), - masterAttachmentId: VERSION.master.attachmentId, + attachmentId: VERSION.attachment.attachmentId, variantId: VERSION.variantId, fileId: DeepSeekFileId('file-api-winner'), bytes: 3, diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 1b14a0c320..547713a74c 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -39,7 +39,7 @@ function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { const hash = String(ref.attachmentId).slice('sha256:'.length) return { variantId: ImageVariantId(`sha256:${hash}`), - master: ref, + attachment: ref, data: new Uint8Array(ref.bytes), mediaType: ref.mediaType, bytes: ref.bytes, @@ -545,7 +545,7 @@ describe('image serialization', () => { ], }) expect(resolveFileId).toHaveBeenCalledTimes(1) - expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ master: { mediaType: 'image/jpeg' } }) + expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ attachment: { mediaType: 'image/jpeg' } }) }) it('rejects an unprepared image while computing exact request bytes', async () => { diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index 480772f5fb..2cad8a22be 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -22,7 +22,7 @@ describe('DeepSeekUploadIndex', () => { const second = deepSeekFileScope('https://api.deepseek.com', 'second-key') const record = { scope: first, - masterAttachmentId: ATTACHMENT, + attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-one'), bytes: 3, @@ -41,7 +41,7 @@ describe('DeepSeekUploadIndex', () => { const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const first = { - scope, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + scope, attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-first'), bytes: 3, createdAt: 1, expiresAt: 10_000, } const duplicate = { ...first, fileId: DeepSeekFileId('file-api-duplicate') } @@ -62,7 +62,7 @@ describe('DeepSeekUploadIndex', () => { const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const record = { scope, - masterAttachmentId: ATTACHMENT, + attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-repaired'), bytes: 3, @@ -73,7 +73,7 @@ describe('DeepSeekUploadIndex', () => { await expect(index.get(scope, VARIANT, 1, 1)).resolves.toBeUndefined() await expect(index.commit(record, 1, 1)).resolves.toEqual({ record, accepted: true }) await expect(index.get(scope, VARIANT, 1, 1)).resolves.toEqual(record) - expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ formatVersion: 2 }) + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ formatVersion: 3 }) }) it.each([ @@ -81,48 +81,49 @@ describe('DeepSeekUploadIndex', () => { '[]', '{}', '{"formatVersion":1,"records":[]}', - '{"formatVersion":2,"records":null}', - '{"formatVersion":2,"records":[null]}', - '{"formatVersion":2,"records":[[]]}', - '{"formatVersion":2,"records":[{}]}', - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'x'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + '{"formatVersion":2,"records":[]}', + '{"formatVersion":3,"records":null}', + '{"formatVersion":3,"records":[null]}', + '{"formatVersion":3,"records":[[]]}', + '{"formatVersion":3,"records":[{}]}', + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'x'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: 'wrong', variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: 'wrong', variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: 'wrong', + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: 'wrong', fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: '', bytes: 3, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: -1, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 1.5, createdAt: 1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: -1, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1.5, expiresAt: 10_000, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: -1, })}]}`, - `{"formatVersion":2,"records":[${JSON.stringify({ - scope: 'a'.repeat(64), masterAttachmentId: ATTACHMENT, variantId: VARIANT, + `{"formatVersion":3,"records":[${JSON.stringify({ + scope: 'a'.repeat(64), attachmentId: ATTACHMENT, variantId: VARIANT, fileId: 'file-api-one', bytes: 3, createdAt: 1, expiresAt: 1.5, })}]}`, ])('treats an invalid persisted index as empty %#', async (text) => { @@ -140,10 +141,10 @@ describe('DeepSeekUploadIndex', () => { const path = join(dir, 'index.json') const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const record = { - scope, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + scope, attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-one'), bytes: 3, createdAt: 1, expiresAt: 10_000, } - await writeFile(path, JSON.stringify({ formatVersion: 2, records: [record, record] }), 'utf8') + await writeFile(path, JSON.stringify({ formatVersion: 3, records: [record, record] }), 'utf8') const index = new DeepSeekUploadIndex(path) await expect(index.get(scope, VARIANT, 1, 1)).resolves.toBeUndefined() }) @@ -154,7 +155,7 @@ describe('DeepSeekUploadIndex', () => { const first = deepSeekFileScope('https://api.deepseek.com', 'first') const second = deepSeekFileScope('https://api.deepseek.com', 'second') const expired = { - scope: first, masterAttachmentId: ATTACHMENT, variantId: VARIANT, + scope: first, attachmentId: ATTACHMENT, variantId: VARIANT, fileId: DeepSeekFileId('file-api-expired'), bytes: 3, createdAt: 1, expiresAt: 2, } const live = { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 038224198d..42364c1d5c 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.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 packages/llm/llm-pi-ai/README.md -README.md: 8f4d1537d8ccec3e89c0553f877541d11b285f66 -README.zh.md: 354851018de0ea79b82215c3d970266cd2be5763 +README.md: 43472de90803481deebb9bc91586a4b85e443db5 +README.zh.md: 76b3dc9dec2a4bf83933319aa065954a191e595b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8f4d1537d8..43472de908 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent master under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading masters, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded masters are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 354851018d..76b3dc9dec 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的主版本派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取主版本前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的主版本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 5473d931de..5fdcc2cdb2 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -52,7 +52,7 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 * Deployments behind stricter gateways lower it per route. */ export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 -/** Default total-pixel budget preserves the complete 2048px local master. */ +/** Default total-pixel budget preserves the complete 2048px normalized attachment. */ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048 /** Default raw encoded-byte cap before inline base64 expansion. */ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 5d2df24d18..9faf457c9a 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -56,10 +56,7 @@ async function userContent( if (block.text.length > 0) content.push({ type: 'text', text: block.text }) break case 'image': { - const version = requestImages.get(block.attachment.attachmentId) - if (version === undefined) { - throw new LlmError(`pi-ai request image ${block.attachment.attachmentId} was not prepared`, 'INVALID_REQUEST') - } + const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment content.push({ type: 'text', text: requestImageHandleText(version) }) content.push({ type: 'image', @@ -106,7 +103,9 @@ async function prepareRequestImages( const refs = new Map() for (const message of messages) collectImageRefs(message.content, refs) const orderedRefs = [...refs.values()] - const prepared = await attachments.readImageRequests(orderedRefs, policy, signal) + const prepared = await Promise.all(orderedRefs.map( + ref => attachments.readImageRequest(ref, policy, signal), + )) const versions = new Map() for (const [index, ref] of orderedRefs.entries()) { versions.set(ref.attachmentId, prepared[index] as RequestImageAttachment) @@ -238,7 +237,7 @@ async function toPiContextWithImages( representation: 'base64', ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, - byteLength: ref => requestImages.get(ref.attachmentId)?.bytes ?? ref.bytes, + byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, }) const toolNames = new Map() const messages: PiMessage[] = [] diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 09befeaa4b..e2ed0233d9 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -6,7 +6,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -246,7 +245,7 @@ describe('PiAiAdapter provider routing', () => { ): Promise => ( Promise.resolve({ variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: value, + attachment: value, data: Uint8Array.of(1), mediaType: value.mediaType, bytes: 1, @@ -272,7 +271,7 @@ describe('PiAiAdapter provider routing', () => { return Promise.reject(new Error('not used')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index da1dcaf28b..026ca2c608 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -22,7 +22,7 @@ const ref: ImageAttachmentRef = { function requestImage(value: ImageAttachmentRef, data: Uint8Array): RequestImageAttachment { return { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - master: value, + attachment: value, data, mediaType: value.mediaType, bytes: data.byteLength, @@ -43,14 +43,7 @@ function projectionStore( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { - readImageRequest, - readImageRequests: ( - refs: readonly ImageAttachmentRef[], - policy: Parameters[1], - signal?: AbortSignal, - ) => Promise.all(refs.map(value => readImageRequest(value, policy, signal))), - } as unknown as AttachmentStore + return { readImageRequest } as unknown as AttachmentStore } const attachments = projectionStore() @@ -418,13 +411,4 @@ describe('pi-ai request context conversion', () => { )).toThrow(/assistant image output/) }) - it('rejects an attachment service that omits a requested image version', async () => { - const store = { - readImageRequests: vi.fn(() => Promise.resolve([])), - } as unknown as AttachmentStore - await expect(toPiContext( - request([user([{ type: 'image', attachment: ref }])]), - store, - )).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) }) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index ccfb321d3b..c540f2d50b 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -46,7 +46,7 @@ async function collect(stream: AsyncIterable): Promise Promise): AttachmentStore { - return { - readImageRequest, - readImageRequests: ( - refs: readonly ImageAttachmentRef[], - policy: ImageRequestPolicy, - signal?: AbortSignal, - ) => Promise.all( - refs.map(ref => readImageRequest(ref, policy, signal)), - ), - } as unknown as AttachmentStore + return { readImageRequest } as unknown as AttachmentStore } describe('toPiContext', () => { diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 93732e75d3..f651aa1f9a 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -7,7 +7,6 @@ import type { ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -81,7 +80,7 @@ async function harness(image?: StoredImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } @@ -98,7 +97,7 @@ async function harness(image?: StoredImageAttachment): Promise { } return Promise.resolve({ variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), - master: fixture.ref, + attachment: fixture.ref, data: fixture.data, mediaType: fixture.ref.mediaType, bytes: fixture.data.byteLength, diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index c30a62dccb..4620275429 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -24,7 +24,7 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { * @returns attachment handle and request-image dimensions. */ export function requestImageHandleText(version: RequestImageAttachment): string { - return `Image ${version.master.attachmentId}; request image ${version.width}x${version.height}px.` + return `Image ${version.attachment.attachmentId}; request image ${version.width}x${version.height}px.` } /** diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 9f4854e2d8..b72b8faad4 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -3,7 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { Context } from '@deepseek-ai/cordis' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { CallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -86,7 +86,7 @@ class RecordingAttachmentStore extends AttachmentStore { return Promise.resolve() } - saveImage(input: SaveImageAttachment): Promise { + saveImage(input: SaveImageAttachment): Promise { this.saved.push(input) const marker = input.data[0] ?? 0 const ref: ImageAttachmentRef = { @@ -96,10 +96,7 @@ class RecordingAttachmentStore extends AttachmentStore { width: 1, height: 1, } - return Promise.resolve({ - ref, - source: { mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }, - }) + return Promise.resolve(ref) } readImage(_ref: ImageAttachmentRef): Promise { diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index d1b4be3058..8285147953 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -653,8 +653,7 @@ describe('/plan', () => { const saveImage = (input: { mediaType: string }) => { saved += 1 return Promise.resolve({ - ref: { attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, - source: { mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }, + attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1, }) } ctx.provide('attachments', { @@ -666,7 +665,7 @@ describe('/plan', () => { saveImage, async saveImages(inputs: readonly { mediaType: string }[]) { const refs = [] - for (const input of inputs) refs.push((await saveImage(input)).ref) + for (const input of inputs) refs.push(await saveImage(input)) return refs }, }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a5ed9feff9..522e98df96 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -296,8 +296,6 @@ export const LINK_MAP: Readonly> = { ImageRequestPolicy: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', - SavedImageAttachment: 'attachment.md', - SourceImageInfo: 'attachment.md', StoredImageAttachment: 'attachment.md', ShellExecRequest: 'shell.md', ShellExecSpec: 'shell.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 805316352b..874f564483 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -25,7 +25,7 @@ import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SavedImageAttachment, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import PlanModeController from '@deepseek-ai/dsh-plan-mode' import WebRuntime from '@deepseek-ai/dsh-web' @@ -83,7 +83,7 @@ class CatalogAttachmentStore extends AttachmentStore { return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest')) } - override saveImage(_input: SaveImageAttachment): Promise { + override saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest')) } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 4f57edc2f8..a3b96a90a3 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -12,7 +12,6 @@ import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, - SavedImageAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -126,7 +125,7 @@ class TestAttachmentStore extends AttachmentStore { return Promise.reject(new Error('test invariant attachment store does not validate images')) } - saveImage(_input: SaveImageAttachment): Promise { + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From cbc830adeddf67c707168041cf9cdbb21e9b55a0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 13:35:20 +0800 Subject: [PATCH 056/248] test(composition): remove retired image-region tool --- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- apps/web/tests/shipped-composition.e2e.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 976381096a..0e98af0477 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -237,7 +237,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'read_image_region', 'send_message', 'skill', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', ]) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index cca21dcef6..295e861b95 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -48,7 +48,6 @@ const EXPECTED_TOOLS = [ 'ralph', 'read', 'read_image', - 'read_image_region', 'send_message', 'skill', 'subagent', From 6a27286e440d96d30916dd77bffa133e00f0d665 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 15:09:14 +0800 Subject: [PATCH 057/248] docs(i18n): fix rebased image note links --- ...b-multimodal-image-input-and-durable-attachments.i18n.yaml | 2 +- ...2-web-multimodal-image-input-and-durable-attachments.zh.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index be716462f5..0702b19390 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md 2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 30ac1dcff9e6400a3bcf58f7b8e5237e20bd5c04 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 68c370c3dd2234e67717429bed417755ed20305d +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 359bb9048632222518d87aadd348bca217c8f7c4 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 68c370c3dd..359bb90486 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -69,7 +69,7 @@ interface ComposerAttachment { 这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时,只有目标外壳接受完整图片批次,图文混合草稿才会移动;拒绝时,文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。准入会应用方向、删除元数据、转换为 8-bit sRGB/sRGBA,并在独立尺寸和字节上限内保持宽高比,生成与提供方无关的主版本。读取会校验摘要、字节长度和已记录元数据。路由专用的确定性请求版本单独缓存,完整策略见[统一图片主版本、请求版本和提供方文件](2026-08-20-unified-image-request-pipeline.md)。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。准入会应用方向、删除元数据、转换为 8-bit sRGB/sRGBA,并在独立尺寸和字节上限内保持宽高比,生成与提供方无关的主版本。读取会校验摘要、字节长度和已记录元数据。路由专用的确定性请求版本单独缓存,完整策略见[统一图片主版本、请求版本和提供方文件](2026-08-20-unified-image-request-pipeline.zh.md)。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -122,7 +122,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查点。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果模型明确排除图片输入,宿主会在写入附件或事件前拒绝新的图片提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一条逐 agent 串行链([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md)),也包括不进入排队 UI 镜像的 steering。这会为提示词和并发选择提供确定顺序。图片进入持久历史后仍可选择纯文本模型;共享 LLM 运行时会在该请求中把保留的图片块替换为确定的文本占位符。`session.updateQueue` 只接受文本内容,因此队列编辑无法绕过准入注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入附件或事件;拒绝会通过 composer 的短时 toast 显示。 +宿主是权威的前置检查点。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果模型明确排除图片输入,宿主会在写入附件或事件前拒绝新的图片提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一条逐 agent 串行链([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.zh.md)),也包括不进入排队 UI 镜像的 steering。这会为提示词和并发选择提供确定顺序。图片进入持久历史后仍可选择纯文本模型;共享 LLM 运行时会在该请求中把保留的图片块替换为确定的文本占位符。`session.updateQueue` 只接受文本内容,因此队列编辑无法绕过准入注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入附件或事件;拒绝会通过 composer 的短时 toast 显示。 Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个保留的图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。两个适配器都从持久主版本请求同一个确定性路由版本。Pi-AI 在考虑 base64 扩张的请求预算内内联携带它。内置 DeepSeek 路由公布 `deepseek-v4-flash-vision-exp`,把每个保留的版本上传到 Files API,并通过索引复用、过期处理、有界陈旧 ID 重试、配额清理和显式删除发送 `file_id` 块。DeepSeek 纯文本模型、未声明图片能力的自定义模型和未列出的透传 ID 保持纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。适配器不得展平或静默跳过保留图片;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 From 6816cc0b04b95a874d2686fa2fd390c38828a737 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 15:25:27 +0800 Subject: [PATCH 058/248] test(snapshot): stabilize persisted-turn coverage --- .../test-support/acp-snapshot/tests/harness.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/test-support/acp-snapshot/tests/harness.spec.ts b/packages/test-support/acp-snapshot/tests/harness.spec.ts index 5bd97b101d..005456e7c0 100644 --- a/packages/test-support/acp-snapshot/tests/harness.spec.ts +++ b/packages/test-support/acp-snapshot/tests/harness.spec.ts @@ -705,9 +705,9 @@ describe('runScenario', () => { it('waitForTurnStart rejects missing, earlier, and malformed durable turns', { timeout: 20_000 }, async () => { const missing = await scenario({}) await expect(runScenario( - { steps: [...boot, { op: 'waitForTurnStart', timeoutMs: 20 }] }, + { steps: [...boot, { op: 'waitForTurnStart', timeoutMs: 200 }] }, { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/start within 20ms/) + )).rejects.toThrow(/did not persist turn\/start within 200ms/) const earlier = await scenario({ prompt: 'hang-until-cancel', @@ -725,11 +725,11 @@ describe('runScenario', () => { steps: [ ...boot, { op: 'promptAndCancel', text: 'hang' }, - { op: 'waitForTurnStart', minimumTurn: 3, timeoutMs: 20 }, + { op: 'waitForTurnStart', minimumTurn: 3, timeoutMs: 200 }, ], }, { agent: AGENT, mode: 'replay', fixtureFile: earlier.fixtureFile }, - )).rejects.toThrow(/turn\/start at or beyond turn 3 within 20ms/) + )).rejects.toThrow(/turn\/start at or beyond turn 3 within 200ms/) const closed = await scenario({ prompt: 'hang-until-cancel', @@ -748,11 +748,11 @@ describe('runScenario', () => { steps: [ ...boot, { op: 'promptAndCancel', text: 'hang' }, - { op: 'waitForTurnStart', timeoutMs: 20 }, + { op: 'waitForTurnStart', timeoutMs: 200 }, ], }, { agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/start within 20ms/) + )).rejects.toThrow(/did not persist turn\/start within 200ms/) for (const turn of [undefined, 0]) { const malformed = await scenario({ From 6ef68c3b96c3e4e6063fe40c155badb5e9f58937 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 15:27:19 +0800 Subject: [PATCH 059/248] docs: add documentation website link --- README.i18n.yaml | 4 ++-- README.md | 2 ++ README.zh.md | 2 ++ .../translation-prompt-v4/request-response.expected.json | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 1550aac8ca..4ce9085d88 100644 --- a/README.i18n.yaml +++ b/README.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 README.md -README.md: 9ccd27b8934449bd0d2311317dc38aee5a5c0cdc -README.zh.md: 103acdefa6bcc161e71224c8aea94a262ff96a67 +README.md: a007b230b0f766537a04c99db920271c96600d1d +README.zh.md: 63899fd3abb2333fcce8b0975c8be7f309845b33 diff --git a/README.md b/README.md index 9ccd27b893..a007b230b0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ DeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek It uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper). +Documentation: [https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/) + ## Developer preview DeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.** diff --git a/README.zh.md b/README.zh.md index 103acdefa6..63899fd3ab 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,6 +6,8 @@ DeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的 它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。 +文档:[https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/) + ## 开发者预览 DeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。** diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index a4b4a2e7c5..2a5ab8fd39 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).\n\nIt uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper).\n\n## Developer preview\n\nDeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.**\n\n## Run\n\n### Run from `npm`\n\nInstall `Node.js`, then run:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command starts the Web UI at `http://127.0.0.1:3080` by default and opens it in the default browser for a local launch. An SSH launch only prints the host URL because the SSH client or editor owns the local forwarded address. Pass `--no-open` to run the server without opening a browser. See [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run from a repository checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` prepares the repository artifacts. `pnpm dsh web` uses those built artifacts without rebuilding.\n\n## Community and support\n\n- Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).\n- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.\n- Join DeepSeek Harness Discord community.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Development\n\nStart with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[MIT](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).\n\nIt uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper).\n\nDocumentation: [https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/)\n\n## Developer preview\n\nDeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.**\n\n## Run\n\n### Run from `npm`\n\nInstall `Node.js`, then run:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command starts the Web UI at `http://127.0.0.1:3080` by default and opens it in the default browser for a local launch. An SSH launch only prints the host URL because the SSH client or editor owns the local forwarded address. Pass `--no-open` to run the server without opening a browser. See [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run from a repository checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` prepares the repository artifacts. `pnpm dsh web` uses those built artifacts without rebuilding.\n\n## Community and support\n\n- Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).\n- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.\n- Join DeepSeek Harness Discord community.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Development\n\nStart with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md).\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[MIT](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令默认会在 `http://127.0.0.1:3080` 启动 Web UI,本机启动时还会用默认浏览器打开页面。通过 SSH 启动时只打印宿主机 URL,因为本地转发地址由 SSH 客户端或编辑器持有。传入 `--no-open` 可仅运行服务器而不打开浏览器。详见 [Web UI 指南](docs/user/guide/index.zh.md)。\n\n\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` 会准备仓库产物。`pnpm dsh web` 会直接使用这些已构建产物,不会重新构建。\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
企微小助手入群问卷微信公众号
\"DeepSeek\"DeepSeek\"DeepSeek
\n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.zh.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。\n\n它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。\n\n文档:[https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/)\n\n## 开发者预览\n\nDeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。**\n\n\n\n## 运行\n\n### 通过 `npm` 运行\n\n安装 `Node.js`,然后运行:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令默认会在 `http://127.0.0.1:3080` 启动 Web UI,本机启动时还会用默认浏览器打开页面。通过 SSH 启动时只打印宿主机 URL,因为本地转发地址由 SSH 客户端或编辑器持有。传入 `--no-open` 可仅运行服务器而不打开浏览器。详见 [Web UI 指南](docs/user/guide/index.zh.md)。\n\n\n\n### 从源码运行\n\n如需从仓库源码运行:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` 会准备仓库产物。`pnpm dsh web` 会直接使用这些已构建产物,不会重新构建。\n\n## 社区与支持\n\n- 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。\n- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。\n- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
企微小助手入群问卷微信公众号
\"DeepSeek\"DeepSeek\"DeepSeek
\n\n## 参与贡献\n\n参见 [CONTRIBUTING.md](CONTRIBUTING.zh.md)。\n\n## 开发\n\n请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。\n\n面向 agent:请遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[MIT](LICENSE)\n\n第三方依赖及其许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。\n" }, { "role": "user", From e30d92a03e990ad4f92863b72061a363af0269b4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 17:27:08 +0800 Subject: [PATCH 060/248] fix(attachment): accept opaque WebP alpha omission --- .../attachment-local/README.i18n.yaml | 4 +-- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/image.ts | 18 +++++++++++++ .../attachment-local/src/normalization.ts | 4 +-- .../attachment-local/src/request-image.ts | 6 ++--- .../tests/normalization.spec.ts | 23 +++++++++++----- .../tests/request-image.spec.ts | 26 +++++++++++++++---- 8 files changed, 65 insertions(+), 20 deletions(-) diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 412e9a4cb6..3698abdcb2 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: 849363ce53c6186359ecad34aecb1c2a48f07441 -README.zh.md: f0fe90c2569f60df48998e46d5b05a0d024959df +README.md: 3ed4ab3251b0a609807c76930226bec63f0164cd +README.zh.md: 85abd10389acc46c2d89dd85628f5d201b089710 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 849363ce53..3ed4ab3251 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Alpha is retained. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index f0fe90c256..85abd10389 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,7 +4,7 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明通道会保留。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index beedd3b8c0..c34944f676 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -23,6 +23,24 @@ export interface DetectedImage { hasAlpha: boolean } +/** + * Check alpha metadata for bytes produced by this package's encoders. + * Sharp/libvips may omit an all-opaque alpha plane from WebP output; every + * other addition or removal indicates that the encoded result is incompatible + * with its source facts. + * @param sourceHasAlpha - whether the source bytes declare an alpha plane, or undefined when the source frame is unspecified. + * @param output - decoded media type and alpha metadata from the encoded result. + * @returns whether the output alpha metadata is compatible with the source. + */ +export function encodedAlphaIsCompatible( + sourceHasAlpha: boolean | undefined, + output: Pick, +): boolean { + return sourceHasAlpha === undefined + || output.hasAlpha === sourceHasAlpha + || (sourceHasAlpha && !output.hasAlpha && output.mediaType === 'image/webp') +} + const MEDIA_TYPES: Readonly> = { png: 'image/png', jpeg: 'image/jpeg', diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts index acfec63c0f..e9ecd8d3e7 100644 --- a/packages/attachment/attachment-local/src/normalization.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -4,7 +4,7 @@ import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' -import { detectImage } from './image.ts' +import { detectImage, encodedAlphaIsCompatible } from './image.ts' import type { DetectedImage } from './image.ts' /** Deployment-resolved policy for the persisted normalized attachment. */ @@ -104,7 +104,7 @@ async function verifyNormalizedImage( || detected.carriesMetadata || detected.depth !== 'uchar' || detected.space !== 'srgb' - || (expectedAlpha !== undefined && detected.hasAlpha !== expectedAlpha)) { + || !encodedAlphaIsCompatible(expectedAlpha, detected)) { throw new AttachmentError( 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', 'ATTACHMENT_WRITE_FAILED', diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index b7c9068bfb..66c427480b 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -14,7 +14,7 @@ import type { } from '@deepseek-ai/dsh-attachment' import { hasLowColourCount } from './normalization.ts' import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' -import { detectImage, probeImage } from './image.ts' +import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v4' @@ -201,7 +201,7 @@ async function readCached( const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width > maximum.width || detected.height > maximum.height - || detected.hasAlpha !== expectedAlpha) return undefined + || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha } } catch (error: unknown) { if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined @@ -217,7 +217,7 @@ async function verifyRequestImage( const detected = await detectImage(image.data) if (detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width !== image.width || detected.height !== image.height - || detected.mediaType !== image.mediaType || detected.hasAlpha !== expectedAlpha) { + || detected.mediaType !== image.mediaType || !encodedAlphaIsCompatible(expectedAlpha, detected)) { throw new AttachmentError( 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', 'ATTACHMENT_WRITE_FAILED', diff --git a/packages/attachment/attachment-local/tests/normalization.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts index 4b530988d1..d43ae45bcd 100644 --- a/packages/attachment/attachment-local/tests/normalization.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -112,18 +112,29 @@ describe('normalizeImage', () => { expect(normalized).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) }) - it('retains an all-opaque alpha channel while converting a low-colour image', async () => { - const data = new Uint8Array(await sharp({ - create: { width: 10, height: 6, channels: 4, background: { r: 12, g: 200, b: 64, alpha: 1 } }, + it('accepts WebP output that omits an all-opaque source alpha plane', async () => { + const width = 64 + const height = 32 + const rgb = noisePixels(width, height) + const rgba = new Uint8Array(width * height * 4) + for (let pixel = 0; pixel < width * height; pixel += 1) { + rgba[pixel * 4] = rgb[pixel * 3] ?? 0 + rgba[pixel * 4 + 1] = rgb[pixel * 3 + 1] ?? 0 + rgba[pixel * 4 + 2] = rgb[pixel * 3 + 2] ?? 0 + rgba[pixel * 4 + 3] = 255 + } + const data = new Uint8Array(await sharp(rgba, { + raw: { width, height, channels: 4 }, }).png().toBuffer()) + await expect(detectImage(data)).resolves.toMatchObject({ hasAlpha: true }) const normalized = await normalizeImage(data, await detectImage(data), { - maxDimension: 5, + maxDimension: 32, maxBytes: POLICY.maxBytes, }) - expect(normalized).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 32, height: 16 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: false }) }) it('keeps transparency when the byte cap requires another encoding and smaller dimensions', async () => { diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 7052da89e8..66932b5e52 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -21,6 +21,23 @@ async function image(width: number, height: number): Promise { }).png().toBuffer()) } +async function complexOpaqueAlphaImage(width: number, height: number): Promise { + const pixels = new Uint8Array(width * height * 4) + let state = 0x2545f491 + for (let offset = 0; offset < pixels.length; offset += 4) { + for (let channel = 0; channel < 3; channel += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + pixels[offset + channel] = state & 0xff + } + pixels[offset + 3] = 255 + } + return new Uint8Array(await sharp(pixels, { + raw: { width, height, channels: 4 }, + }).png().toBuffer()) +} + afterEach(async () => { await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) }) @@ -208,16 +225,15 @@ describe('local request-image cache', () => { }) }) - it('retains an all-opaque alpha channel in a resized request version', async () => { + it('accepts a resized WebP request version that omits an all-opaque alpha plane', async () => { const attachments = await store() - const source = new Uint8Array(await sharp({ - create: { width: 64, height: 32, channels: 4, background: { r: 12, g: 34, b: 56, alpha: 1 } }, - }).png().toBuffer()) + const source = await complexOpaqueAlphaImage(64, 32) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) - await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: true }) + expect(request.mediaType).toBe('image/webp') + await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false }) }) it('keeps a complex 640,000-pixel request version below 1 MiB', async () => { From 1b389798dcab65d2a29f673aa25ab4e68ca7876f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 18:14:22 +0800 Subject: [PATCH 061/248] fix(llm-deepseek): fall back when Files resolution fails --- ...1-deepseek-files-inline-fallback.i18n.yaml | 6 + ...26-08-21-deepseek-files-inline-fallback.md | 37 +++ ...08-21-deepseek-files-inline-fallback.zh.md | 37 +++ ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 8 +- ...08-20-unified-image-request-pipeline.zh.md | 8 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 10 +- examples/acp-agent/tests/acp.snapshot.ts | 42 ++- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 15 +- packages/llm/llm-deepseek/README.zh.md | 15 +- packages/llm/llm-deepseek/src/adapter.ts | 99 ++++-- packages/llm/llm-deepseek/src/index.ts | 43 ++- packages/llm/llm-deepseek/src/serialize.ts | 66 ++-- packages/llm/llm-deepseek/src/types.ts | 11 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 299 +++++++++++++++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 64 +++- 19 files changed, 695 insertions(+), 87 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml new file mode 100644 index 0000000000..ed4af5577d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.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 .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md +2026-08-21-deepseek-files-inline-fallback.md: c58b3e2257b426f1b5df8a4d6952e890a2bd2982 +2026-08-21-deepseek-files-inline-fallback.zh.md: 34625c6250d52a73ccaac3e33adbd2ed099aab5b diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md new file mode 100644 index 0000000000..c58b3e2257 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md @@ -0,0 +1,37 @@ +# Agent Note: Recover DeepSeek image requests from Files resolution failures + +Status: implemented + +English | [中文](2026-08-21-deepseek-files-inline-fallback.zh.md) + +## Problem + +The direct DeepSeek vision route uses provider file ids so repeated requests do not resend image bytes. An unavailable, unsupported, or stalled Files endpoint can prevent chat before the model request begins even though the same endpoint still accepts inline image data. A fallback that retains the 128MiB Files budget would exceed the inline request-body limit, while a fallback that independently transforms images could send different pixels from the failed file-id attempt. + +## Decision + +Files remains the preferred transport. Each request-image file resolution has the configurable `filesApiTimeoutMs` deadline, one minute by default and always below `streamIdleTimeoutMs`. Successful resolutions refresh the outer idle watchdog. Caller cancellation and the outer stream deadline remain terminal outcomes. + +A file resolution failure discards the transient file parts assembled for that chat attempt and rebuilds the complete image request with base64 data URLs. Every retained image uses the already prepared deterministic `RequestImageAttachment`; the fallback performs no additional decode, resize, or encode, and a chat request never mixes file ids with inline images. Upload mappings committed before a later image fails remain available to later requests. The next request tries Files again, so recovery requires no process-wide outage state. + +Inline fallback has a separate base64-expanded high watermark, `maxInlineRequestImageBytes`, of 20MiB by default. `inlineImageOffloadByteQuantum` defaults to 10MiB, so crossing the high watermark advances the deterministic oldest-image prefix to the next 10MiB removal boundary. The existing 600-image bound and count quantum still apply. File mode retains its 128MiB high watermark and 64MiB removal quantum. + +Provider chat errors keep their existing classifications. A stale file id is invalidated, re-uploaded, and retried once. If that replacement resolution fails, the permitted retry uses the inline representation. A generic chat failure does not switch transports because it does not establish that Files resolution failed. + +## Alternatives considered + +**Send inline images first.** Rejected because successful Files uploads allow deterministic request bytes to be reused across turns without repeating base64 in every request. + +**Mix resolved file ids with inline images after one upload fails.** Rejected because the request would still depend on the failing Files service and would have two independent image budgets. + +**Apply the 128MiB Files bound to inline fallback.** Rejected because base64 expands the payload and can exceed the chat request-body limit. The 20MiB budget leaves space for JSON, text history, and tools. + +**Remember an outage and bypass Files on later requests.** Rejected because a process-local circuit state introduces recovery timing and shared failure state. Retrying Files on the next request detects service recovery without another timer. + +## Verification + +Serializer tests cover file and data-URL representations over the same request versions, all supported media types, tool-result placement, and 20-to-10 base64 offload. Adapter tests cover immediate resolution failure, failure after a partial set of file ids, deadline-triggered fallback, stale-id replacement failure, all-inline request bodies, caller cancellation without fallback, and generic chat failure without a transport switch. Configuration tests cover both inline bounds and the Files deadline relationship. + +## Consequences + +A Files outage no longer prevents an image chat that fits the inline budget. Fallback repeats image bytes and may omit more history than file mode because its limit is lower. A request can leave successful uploads behind when a later image fails, but their indexed mappings are reusable and do not change the chat body sent by the fallback. Explicit file-management operations continue to expose their own failures. diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md new file mode 100644 index 0000000000..34625c6250 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md @@ -0,0 +1,37 @@ +# Agent Note: DeepSeek Files 解析失败时恢复图片请求 + +Status: implemented + +[English](2026-08-21-deepseek-files-inline-fallback.md) | 中文 + +## Problem + +DeepSeek 官方视觉路由使用提供方文件 ID,使重复请求不必再次发送图片字节。如果 Files 端点不可用、不受支持或一直不返回,chat 会在模型请求开始前失败,即使同一端点仍接受内联图片数据。沿用 128MiB Files 预算的回退会超过内联请求体上限,独立转换图片的回退则可能发送与失败 file ID 尝试不同的像素。 + +## Decision + +Files 仍是首选传输方式。每张请求图片的文件解析都有可配置的 `filesApiTimeoutMs` 时限,默认一分钟,且始终小于 `streamIdleTimeoutMs`。每次成功解析都会刷新外层 idle watchdog。调用方取消和外层流时限仍直接终止请求。 + +文件解析失败后,适配器会丢弃为该次 chat 尝试组装的临时文件块,并用 base64 data URL 重新组装完整图片请求。每张保留图片都复用已经准备好的确定性 `RequestImageAttachment`;回退不会再次解码、缩放或编码,同一个 chat 请求也不会混用 file ID 和内联图片。较早图片在后续图片失败前已经提交的上传映射会保留,供之后请求使用。下一次请求会重新尝试 Files,因此不需要保存进程级故障状态。 + +内联回退使用独立的 base64 膨胀后高水位,`maxInlineRequestImageBytes` 默认为 20MiB。`inlineImageOffloadByteQuantum` 默认为 10MiB,因此越过高水位时,确定性的最旧图片前缀会推进到下一个 10MiB 移除边界。现有 600 张图片上限和数量步长继续生效。文件模式继续使用 128MiB 高水位和 64MiB 移除步长。 + +提供方 chat 错误继续使用现有分类。失效 file ID 会被清除、重新上传并重试一次。如果替换解析失败,这次允许的重试会使用内联表示。普通 chat 错误不能证明 Files 解析失败,因此不会切换传输方式。 + +## Alternatives considered + +**优先发送内联图片。** 不采用,因为 Files 上传成功后可以跨轮次复用确定性的请求字节,不必在每次请求中重复 base64。 + +**某次上传失败后混用已解析 file ID 和内联图片。** 不采用,因为请求仍依赖发生故障的 Files 服务,而且需要同时处理两套图片预算。 + +**把 128MiB Files 上限用于内联回退。** 不采用,因为 base64 会扩大负载,并可能超过 chat 请求体上限。20MiB 预算会为 JSON、文本历史和工具留下空间。 + +**记住故障,并在后续请求中跳过 Files。** 不采用,因为进程级状态会引入恢复时间和共享故障状态。下一次请求重新尝试 Files,可以在无需新增计时器的情况下发现服务恢复。 + +## Verification + +序列化测试覆盖相同请求版本的文件和 data URL 表示、全部支持的媒体类型、工具结果位置,以及 20MiB 到 10MiB 的 base64 offload。适配器测试覆盖立即解析失败、部分 file ID 成功后的失败、时限触发的回退、失效 ID 替换失败、全内联请求体、调用方取消时不回退,以及普通 chat 错误不切换传输方式。配置测试覆盖两项内联预算和 Files 时限关系。 + +## Consequences + +符合内联预算的图片 chat 不会再因 Files 故障而失败。回退会重复发送图片字节,而且由于上限更低,可能比文件模式省略更多历史。后续图片失败时,请求可能留下较早图片的成功上传,但这些索引映射可以复用,也不会改变回退发送的 chat 请求体。显式文件管理操作继续暴露自身错误。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 1c6145c359..6a379a3532 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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 .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 6a3bae8a970677c32bbfb7966d2bc13d4e504804 -2026-08-20-unified-image-request-pipeline.zh.md: 10a4aed0b5ca9168c6a6ee4ec0258a210b50d531 +2026-08-20-unified-image-request-pipeline.md: ada15d540539977c631e359ffdc7baa4fa84c78e +2026-08-20-unified-image-request-pipeline.zh.md: 85c9a1f837d82cba2bc62b30402433f50c873cbe diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 6a3bae8a97..ada15d5405 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -34,7 +34,7 @@ Every retained request image is preceded by its complete attachment id and actua ### DeepSeek Files lifecycle -The direct `deepseek-official` adapter uploads every retained request version through the OpenAI-compatible Files API and sends only `file_id` content blocks. There is no inline fallback. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by endpoint and API-key scope plus `variantId`. Uploads request seven days by default and record the returned `expires_at`; a mapping with no more than one hour remaining is replaced without a preceding retrieve call. The index never stores the API key. +The direct `deepseek-official` adapter normally uploads every retained request version through the OpenAI-compatible Files API and sends `file_id` content blocks. A [bounded inline fallback](../bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by endpoint and API-key scope plus `variantId`. Uploads request seven days by default and record the returned `expires_at`; a mapping with no more than one hour remaining is replaced without a preceding retrieve call. The index never stores the API key. An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. @@ -52,7 +52,7 @@ Historical attachment objects that later disappear or fail integrity verificatio **Treat PNG as a screenshot and reject 16-bit PNG.** File format does not reveal pixel complexity, and 16-bit RGB/RGBA is a convertible sample depth rather than an unsupported image type. Pixel sampling and post-conversion probes give the required facts. -**Keep DeepSeek data URLs.** Inline base64 repeats bytes on every request and caps usable image history by request-body size. Files API references reuse uploaded deterministic request bytes and provide explicit expiry and deletion. +**Keep DeepSeek data URLs as the primary transport.** Inline base64 repeats bytes on every request and caps usable image history by request-body size. Files API references reuse uploaded deterministic request bytes and provide explicit expiry and deletion; the bounded fallback uses data URLs only when file resolution fails. **Trust a locally indexed file id indefinitely.** Remote expiry, deletion, and lost upload responses make local and provider state diverge. Response-directed invalidation and one re-upload recover without an unbounded retry loop; an ambiguous stale-file response must invalidate every file used by that attempt because it provides no safe exact target. @@ -62,8 +62,8 @@ Historical attachment objects that later disappear or fail integrity verificatio ## Verification -Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. ## Consequences -Normalized attachments consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests now depend on Files API availability; bounded stale-id recovery handles inconsistent remote state, while a general Files outage remains a visible request failure. Missing or corrupt durable attachments still require the separate quarantine design. +Normalized attachments consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests prefer Files reuse; bounded stale-id recovery handles inconsistent remote state, while file-resolution failures use the smaller inline budget. Missing or corrupt durable attachments still require the separate quarantine design. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 10a4aed0b5..85c9a1f837 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -34,7 +34,7 @@ Status: implemented ### DeepSeek Files 生命周期 -直接 `deepseek-official` 适配器通过 OpenAI 兼容 Files API 上传每张保留的请求版本,只发送 `file_id` 内容块,不提供内联回退。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期,并记录返回的 `expires_at`;本地映射剩余时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 +直接 `deepseek-official` 适配器通常通过 OpenAI 兼容 Files API 上传每张保留的请求版本,并发送 `file_id` 内容块。文件解析失败时,[有界内联回退](../bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期,并记录返回的 `expires_at`;本地映射剩余时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 @@ -52,7 +52,7 @@ Status: implemented **把 PNG 当作截图,并拒绝 16-bit PNG。** 文件格式不能说明像素复杂度,16-bit RGB/RGBA 是可转换位深,不是不支持的图片类型。像素采样和转换后探测能提供所需事实。 -**继续向 DeepSeek 发送 data URL。** 内联 base64 会在每次请求中重复字节,并按请求正文大小限制可用图片历史。Files API 引用会复用上传后的确定性请求字节,并提供显式有效期和删除操作。 +**把 DeepSeek data URL 作为首选传输方式。** 内联 base64 会在每次请求中重复字节,并按请求正文大小限制可用图片历史。Files API 引用会复用上传后的确定性请求字节,并提供显式有效期和删除操作;有界回退只在文件解析失败时使用 data URL。 **永久信任本地索引中的文件 ID。** 远端过期、删除和上传响应丢失会使本地与提供方状态不一致。按响应失效和一次重新上传可以恢复,同时避免无界重试;响应没有给出可安全使用的精确目标时,必须使该次请求使用的全部文件失效。 @@ -62,8 +62,8 @@ Status: implemented ## Verification -包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 ## Consequences -持久规范化附件最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求现在依赖 Files API 可用性;有界的陈旧 ID 恢复会处理远端状态不一致,一般 Files 故障仍会成为可见请求失败。缺失或损坏的持久附件仍需要单独的隔离设计。 +持久规范化附件最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求优先复用 Files;有界的陈旧 ID 恢复会处理远端状态不一致,文件解析失败则使用较小的内联预算。缺失或损坏的持久附件仍需要单独的隔离设计。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7b1abc90d1..d0665a143e 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 552c09c08abef2cab957d2a8caab9412cb4522e5 -config-catalog.zh.md: fc1993bf4c4f21ec6ec85341f4ce09a04b6a8b66 +config-catalog.md: de340b7ffade528301b4538b0553bc11ec969985 +config-catalog.zh.md: eb17ee89fd7860cc0774073bea542aca315ce652 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 552c09c08a..de340b7ffa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -940,12 +940,18 @@ export interface Config { streamIdleTimeoutMs?: number /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ maxRequestFilesBytes?: number - /** Maximum number of file-referenced images per chat request (default 600). */ + /** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */ + maxInlineRequestImageBytes?: number + /** Maximum number of represented images per chat request (default 600). */ maxImagesPerRequest?: number /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ imageOffloadByteQuantum?: number + /** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */ + inlineImageOffloadByteQuantum?: number /** Image-count removal step after the request exceeds its count bound (default 20). */ imageOffloadCountQuantum?: number + /** Maximum duration of one request-image Files API resolution (default one minute). */ + filesApiTimeoutMs?: number /** Explicit lifetime assigned to each uploaded image (default seven days). */ fileExpiresAfterSeconds?: number /** Remaining lifetime below which an indexed file is replaced (default one hour). */ @@ -981,7 +987,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:100`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index fc1993bf4c..eb17ee89fd 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -942,12 +942,18 @@ export interface Config { streamIdleTimeoutMs?: number /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ maxRequestFilesBytes?: number - /** Maximum number of file-referenced images per chat request (default 600). */ + /** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */ + maxInlineRequestImageBytes?: number + /** Maximum number of represented images per chat request (default 600). */ maxImagesPerRequest?: number /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ imageOffloadByteQuantum?: number + /** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */ + inlineImageOffloadByteQuantum?: number /** Image-count removal step after the request exceeds its count bound (default 20). */ imageOffloadCountQuantum?: number + /** Maximum duration of one request-image Files API resolution (default one minute). */ + filesApiTimeoutMs?: number /** Explicit lifetime assigned to each uploaded image (default seven days). */ fileExpiresAfterSeconds?: number /** Remaining lifetime below which an indexed file is replaced (default one hour). */ @@ -983,7 +989,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:100`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 548b4025a4..0bfbe4e3ec 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -702,9 +702,10 @@ defineAcpSnapshotSuite({ hasPwsh, }) -it('pins native DeepSeek Files image offload in the request sent by the assembled app', async () => { +it('pins native DeepSeek Files offload and inline fallback in assembled requests', async () => { const requests: Record[] = [] const fileRequests: Array<{ method: string; path: string; bytes: number }> = [] + let rejectFiles = false const server = createServer((request: IncomingMessage, response: ServerResponse) => { const chunks: Buffer[] = [] request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) @@ -723,6 +724,12 @@ it('pins native DeepSeek Files image offload in the request sent by the assemble const file = form.get('file') if (!(file instanceof Blob)) throw new Error('snapshot Files upload omitted file') fileRequests.push({ method: 'POST', path: url.pathname, bytes: file.size }) + if (rejectFiles) { + response.writeHead(503, { 'content-type': 'application/json' }).end(JSON.stringify({ + error: { message: 'Files temporarily unavailable' }, + })) + return + } const createdAt = Math.floor(Date.now() / 1_000) response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ id: 'file-api-snapshot-1', @@ -861,6 +868,39 @@ it('pins native DeepSeek Files image offload in the request sent by the assemble ], }, ]) + + rejectFiles = true + const fallback = await runScenario(input, { + agent: AGENT, + mode: 'record', + configPath: IMAGE_OFFLOAD_CONFIG, + fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), + workspaceDir: join(SNAPSHOTS_DIR, 'read-image', 'workspace'), + env: { + DSH_SNAPSHOT_API_KEY: 'snapshot-fallback-key', + DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, + }, + }) + expect(fallback.stderr).toBe('') + expect(fileRequests).toEqual([ + { method: 'POST', path: '/files', bytes: 69 }, + { method: 'POST', path: '/files', bytes: 69 }, + ]) + expect(requests).toHaveLength(3) + const fallbackMessages = requests[2]?.messages as { content?: unknown }[] | undefined + const fallbackInput = fallbackMessages?.find(message => JSON.stringify(message.content).includes('[image omitted')) + expect(fallbackInput?.content).toEqual([ + { type: 'text', text: 'Compare the older image ' }, + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: ' with the newer image ' }, + { + type: 'text', + text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' + + 'request image 1x1px.', + }, + { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }, + { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, + ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index c4db847155..e254c6267d 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: d17d520c2444d8a0195d997f4df4ff5e0f05befd -README.zh.md: cc823897894102df0dc1da17478eee6ba7ebd21d +README.md: 7a22955565027b30677e46a80a8b719bc7e61917 +README.zh.md: db1669509956d651dcb8948e1191a17cf9a0bfee diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index d17d520c24..7a22955565 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -21,9 +21,12 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default maxRequestFilesBytes: 134217728 # optional positive integer; 128 MiB raw request-image default + maxInlineRequestImageBytes: 20971520 # base64 fallback high watermark; 20 MiB default maxImagesPerRequest: 600 # provider request image-count limit imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps + inlineImageOffloadByteQuantum: 10485760 # fallback removal advances in 10 MiB steps imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps + filesApiTimeoutMs: 60000 # per-image Files resolution deadline; below streamIdleTimeoutMs fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -49,11 +52,13 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. It never falls back to an inline data URL. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. -Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the normalized attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline-capable adapters refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload; permission and filesystem I/O failures still fail the request. +Inline fallback has an independent base64 budget. `maxInlineRequestImageBytes` defaults to 20MiB and `inlineImageOffloadByteQuantum` to 10MiB, so a history of 21 one-megabyte base64 payloads removes the oldest 11 and retains 10MiB. The calculation uses base64-expanded lengths. The prepared request versions are reused byte-for-byte; fallback does not decode or compress an image again. Successful mappings created before a later image fails remain indexed for future requests. + +Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the normalized attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline fallback refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload. File resolution, including local index access and remote upload, has a per-image one-minute deadline by default; it must remain below `streamIdleTimeoutMs`. Each successful resolution refreshes the outer idle watchdog. Any resolution failure switches that request to inline mode, while explicit public file-management operations continue to report their own failures. Concurrent resolution of one scoped `variantId` shares one Files upload with waiter-local cancellation. One quota upload failure first paginates and collects the configured number of oldest `dsh-` files, then deletes that set before one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. @@ -65,7 +70,7 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `low`, `high`, or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter normally makes one chat request per `stream()` call and makes a second only for the stale-file recovery described above. It registers the configured retry policy as provider metadata, and `dsh-llm-retry` separately executes that policy at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments and successful file resolutions rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter normally makes one chat request per `stream()` call and makes a second only for stale-file recovery. A file-resolution failure before the first chat sends one inline request. If replacement resolution fails after a stale-file response, the inline request is the one permitted retry. It registers the configured retry policy as provider metadata, and `dsh-llm-retry` separately executes that policy at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) @@ -104,7 +109,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config. The vision model receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; an over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; a Files resolution failure sends all retained images as inline data URLs instead. An over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect @@ -134,4 +139,4 @@ Loop-retained response blocks append to the next request and preserve its earlie - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. -- **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input uses the Files API. +- **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index cc82389789..db16695099 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -21,9 +21,12 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default maxRequestFilesBytes: 134217728 # optional positive integer; 128 MiB raw request-image default + maxInlineRequestImageBytes: 20971520 # base64 fallback high watermark; 20 MiB default maxImagesPerRequest: 600 # provider request image-count limit imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps + inlineImageOffloadByteQuantum: 10485760 # fallback removal advances in 10 MiB steps imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps + filesApiTimeoutMs: 60000 # per-image Files resolution deadline; below streamIdleTimeoutMs fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -49,11 +52,13 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块,不会回退到内联 data URL。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 `maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 -上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和支持内联的适配器引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换;权限和文件系统 I/O 错误仍使请求失败。 +内联回退使用独立的 base64 预算。`maxInlineRequestImageBytes` 默认为 20MiB,`inlineImageOffloadByteQuantum` 默认为 10MiB,因此由 21 个 1MiB base64 负载组成的历史会移除最旧的 11 个并保留 10MiB。计算使用 base64 膨胀后的长度。系统逐字节复用已经准备好的请求版本;回退不会再次解码或压缩图片。前面图片已经成功写入的上传映射会保留,供后续请求复用。 + +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和内联回退引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换。文件解析包括本地索引访问和远端上传,默认每张图片的时限为一分钟,且必须小于 `streamIdleTimeoutMs`。每次成功解析都会刷新外层 idle watchdog。任何解析失败都会把该请求切换到内联模式;显式公共文件管理操作仍会报告自身错误。 同一作用域和 `variantId` 的并发解析共享一次 Files 上传,每个等待方可以单独取消。一次上传配额错误会先分页收集配置数量的最旧 `dsh-` 文件,再删除这些文件并重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 @@ -65,7 +70,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `low`、`high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器通常每次 `stream()` 调用发起一次 chat 请求,只有上述失效文件恢复会发起第二次。适配器把已配置重试策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释和成功的文件解析会作为传输活动使尚未完成的读取重新计时,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器通常每次 `stream()` 调用发起一次 chat 请求,只有失效文件恢复会发起第二次。首次 chat 前的文件解析失败会发送一次内联请求。如果失效文件响应后的替换解析失败,该内联请求就是唯一允许的重试。适配器把已配置重试策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) @@ -104,7 +109,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置。视觉模型会通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;Files 解析失败时,所有保留图片改用内联 data URL。超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 @@ -134,4 +139,4 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **会跳过插件添加的内容块类型**:核心文本与支持的图片块会被序列化,空工具输出会以字面 `(no output)` 通过协议发送。 -- **图片是仅输入的持久附件**:不支持直接外部 URL 和 assistant 图片输出;DeepSeek 图片输入使用 Files API。 +- **图片是仅输入的持久附件**:不支持直接外部 URL 和 assistant 图片输出;DeepSeek 图片输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 9c4756f3d3..8c30333131 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -28,7 +28,7 @@ import type { RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' -import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { deadline, idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { serializeRequest, serializeRequestWithImages } from './serialize.ts' import type { ImageWireLocation, RequestDefaults } from './serialize.ts' @@ -37,7 +37,7 @@ import type { DeepSeekFilePolicy } from './file-store.ts' import type { DeepSeekFileId } from './file-id.ts' import { parseSse } from './sse.ts' import { translate } from './translate.ts' -import type { WireError } from './types.ts' +import type { WireError, WireRequest } from './types.ts' /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { @@ -89,12 +89,18 @@ export interface DeepSeekConnectionOptions { streamIdleTimeoutMs: number /** Maximum accumulated file-referenced image bytes in one request. */ maxRequestFilesBytes: number - /** Maximum number of file-referenced images in one request. */ + /** Maximum accumulated base64 image payload after Files API fallback. */ + maxInlineRequestImageBytes: number + /** Maximum number of represented images in one request. */ maxImagesPerRequest: number /** Raw-byte removal step after the file-reference bound is exceeded. */ imageOffloadByteQuantum: number + /** Base64-byte removal step after the inline fallback bound is exceeded. */ + inlineImageOffloadByteQuantum: number /** Image-count removal step after the count bound is exceeded. */ imageOffloadCountQuantum: number + /** Maximum duration of one request-image Files API resolution. */ + filesApiTimeoutMs: number /** Upload expiry, refresh, and quota-recovery policy. */ filePolicy: DeepSeekFilePolicy /** Provider-owned model-request retry policy, already resolved. */ @@ -128,6 +134,8 @@ export const DEFAULT_CONTEXT_WINDOW = 1_000_000 export const DEFAULT_MAX_TOKENS = 256_000 /** Default bound on accumulated file-referenced image bytes per request. */ export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024 +/** Default bound on accumulated base64 image payload after Files API fallback. */ +export const DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 /** Provider request image-count limit. */ export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600 /** Total-pixel budget matching DeepSeek's normal vision projection. */ @@ -138,6 +146,8 @@ export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512 export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Deterministic raw-byte removal step. */ export const DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM = 64 * 1024 * 1024 +/** Deterministic base64-byte removal step after Files API fallback. */ +export const DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM = 10 * 1024 * 1024 /** Deterministic image-count removal step. */ export const DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM = 20 /** Default explicit lifetime for uploaded images. */ @@ -146,7 +156,10 @@ export const DEFAULT_FILE_EXPIRY_SECONDS = 7 * 24 * 60 * 60 export const DEFAULT_FILE_REFRESH_MARGIN_SECONDS = 60 * 60 /** Default number of oldest harness-owned files removed on quota recovery. */ export const DEFAULT_FILE_QUOTA_CLEANUP_BATCH = 100 +/** Default deadline for resolving one request image through the Files API. */ +export const DEFAULT_FILES_API_TIMEOUT_MS = 60_000 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' +const FILES_API_TIMEOUT_CODE = 'DEEPSEEK_FILES_API_TIMEOUT' const OFF_REASONING_EFFORT = ReasoningEffortId('off') const LOW_REASONING_EFFORT = ReasoningEffortId('low') const HIGH_REASONING_EFFORT = ReasoningEffortId('high') @@ -161,6 +174,14 @@ const OFF_ONLY_REASONING_EFFORTS = [ { id: OFF_REASONING_EFFORT, name: 'Off' }, ] as const +/** Marks a failed file-id resolution that may be retried as an inline request. */ +class FileResolutionFailure extends Error { + constructor(cause: unknown) { + super('DeepSeek Files API could not resolve a request image.', { cause }) + this.name = 'FileResolutionFailure' + } +} + function collectImageRefs( content: readonly ContentBlock[], refs: Map, @@ -494,7 +515,7 @@ export class DeepSeekAdapter extends LlmAdapter { apiKey: string, userId: AnonymousUserId, attachments: AttachmentStore | undefined, - onComment: () => void, + onActivity: () => void, ): AsyncIterable { const headers = { 'authorization': `Bearer ${apiKey}`, @@ -525,27 +546,58 @@ export class DeepSeekAdapter extends LlmAdapter { const requestImages = attachments === undefined || model === undefined ? new Map() : await prepareRequestImages(requestOptions, attachments, model, signal) - for (let fileAttempt = 0; fileAttempt < 2; fileAttempt += 1) { + let representation: 'file' | 'base64' = 'file' + let fileAttempt = 0 + while (true) { const usedFiles: UsedRequestFile[] = [] - const body = attachments === undefined - ? serializeRequest(requestOptions, connection.defaults) - : await serializeRequestWithImages(requestOptions, { + let body: WireRequest + if (attachments === undefined) { + body = serializeRequest(requestOptions, connection.defaults) + } else if (representation === 'base64') { + body = await serializeRequestWithImages(requestOptions, { + representation: { kind: 'base64' }, requestImages, - resolveFileId: async (version, _block, location) => { - const resolved = await this.files.ensureUploaded( - version, - fileConnection, - connection.filePolicy, - signal, - ) - usedFiles.push({ version, fileId: resolved.record.fileId, location }) - return resolved.record.fileId - }, - maxRequestFilesBytes: connection.maxRequestFilesBytes, + maxRequestImageBytes: connection.maxInlineRequestImageBytes, maxImagesPerRequest: connection.maxImagesPerRequest, - byteQuantum: connection.imageOffloadByteQuantum, + byteQuantum: connection.inlineImageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, }, connection.defaults) + } else { + try { + body = await serializeRequestWithImages(requestOptions, { + representation: { + kind: 'file', + resolveFileId: async (version, _block, location) => { + using filesDeadline = deadline(signal, connection.filesApiTimeoutMs, FILES_API_TIMEOUT_CODE) + let resolved: Awaited> + try { + resolved = await this.files.ensureUploaded( + version, + fileConnection, + connection.filePolicy, + filesDeadline.signal, + ) + } catch (error: unknown) { + if (signal.aborted) throw error + throw new FileResolutionFailure(error) + } + onActivity() + usedFiles.push({ version, fileId: resolved.record.fileId, location }) + return resolved.record.fileId + }, + }, + requestImages, + maxRequestImageBytes: connection.maxRequestFilesBytes, + maxImagesPerRequest: connection.maxImagesPerRequest, + byteQuantum: connection.imageOffloadByteQuantum, + countQuantum: connection.imageOffloadCountQuantum, + }, connection.defaults) + } catch (error: unknown) { + if (!(error instanceof FileResolutionFailure)) throw error + representation = 'base64' + continue + } + } const payload = JSON.stringify(body) // TODO(http): adopt the Cordis HTTP service when shared transport configuration @@ -586,7 +638,10 @@ export class DeepSeekAdapter extends LlmAdapter { await Promise.all(staleMappings(usedFiles, detail).map(file => ( this.files.invalidate(file.version, file.fileId, fileConnection) ))) - if (fileAttempt === 0) continue + if (fileAttempt === 0) { + fileAttempt += 1 + continue + } } if (response.status === 400 && usedFiles.length > 0 && providerRejectedNormalizedImage(detail)) { message = normalizedImageDiagnostic(usedFiles, message, detail) @@ -604,7 +659,7 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') } - yield* translate(parseSse(response.body, onComment)) + yield* translate(parseSse(response.body, onActivity)) return } } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 919168439d..e8632c22da 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -25,9 +25,12 @@ import { DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, + DEFAULT_FILES_API_TIMEOUT_MS, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, + DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, + DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_MAX_TOKENS, @@ -43,9 +46,12 @@ export { DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, + DEFAULT_FILES_API_TIMEOUT_MS, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, + DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, + DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_MAX_TOKENS, @@ -116,12 +122,18 @@ export interface Config { streamIdleTimeoutMs?: number /** Maximum accumulated file-referenced image bytes per chat request (default 128 MiB). */ maxRequestFilesBytes?: number - /** Maximum number of file-referenced images per chat request (default 600). */ + /** Maximum accumulated base64 image payload after Files API fallback (default 20 MiB). */ + maxInlineRequestImageBytes?: number + /** Maximum number of represented images per chat request (default 600). */ maxImagesPerRequest?: number /** Raw-byte removal step after the request exceeds its file bound (default 64 MiB). */ imageOffloadByteQuantum?: number + /** Base64-byte removal step after inline fallback exceeds its bound (default 10 MiB). */ + inlineImageOffloadByteQuantum?: number /** Image-count removal step after the request exceeds its count bound (default 20). */ imageOffloadCountQuantum?: number + /** Maximum duration of one request-image Files API resolution (default one minute). */ + filesApiTimeoutMs?: number /** Explicit lifetime assigned to each uploaded image (default seven days). */ fileExpiresAfterSeconds?: number /** Remaining lifetime below which an indexed file is replaced (default one hour). */ @@ -154,9 +166,12 @@ export const Config: z = z.object({ models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), maxRequestFilesBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_FILES_BYTES), + maxInlineRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES), maxImagesPerRequest: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_REQUEST), imageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM), + inlineImageOffloadByteQuantum: z.number().step(1).min(1).default(DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM), imageOffloadCountQuantum: z.number().step(1).min(1).default(DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM), + filesApiTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_FILES_API_TIMEOUT_MS), fileExpiresAfterSeconds: z.number().step(1).min(3_600).max(2_592_000).default(DEFAULT_FILE_EXPIRY_SECONDS), fileRefreshMarginSeconds: z.number().step(1).min(0).default(DEFAULT_FILE_REFRESH_MARGIN_SECONDS), fileQuotaCleanupBatch: z.number().step(1).min(1).max(1_000).default(DEFAULT_FILE_QUOTA_CLEANUP_BATCH), @@ -283,6 +298,10 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro if (!Number.isSafeInteger(maxRequestFilesBytes) || maxRequestFilesBytes <= 0) { throw new Error('llm-deepseek: maxRequestFilesBytes must be a positive safe integer') } + const maxInlineRequestImageBytes = config.maxInlineRequestImageBytes ?? DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES + if (!Number.isSafeInteger(maxInlineRequestImageBytes) || maxInlineRequestImageBytes <= 0) { + throw new Error('llm-deepseek: maxInlineRequestImageBytes must be a positive safe integer') + } const maxImagesPerRequest = config.maxImagesPerRequest ?? DEFAULT_MAX_IMAGES_PER_REQUEST if (!Number.isSafeInteger(maxImagesPerRequest) || maxImagesPerRequest <= 0) { throw new Error('llm-deepseek: maxImagesPerRequest must be a positive safe integer') @@ -294,6 +313,14 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro if (imageOffloadByteQuantum > maxRequestFilesBytes) { throw new Error('llm-deepseek: imageOffloadByteQuantum must not exceed maxRequestFilesBytes') } + const inlineImageOffloadByteQuantum = config.inlineImageOffloadByteQuantum + ?? DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM + if (!Number.isSafeInteger(inlineImageOffloadByteQuantum) || inlineImageOffloadByteQuantum <= 0) { + throw new Error('llm-deepseek: inlineImageOffloadByteQuantum must be a positive safe integer') + } + if (inlineImageOffloadByteQuantum > maxInlineRequestImageBytes) { + throw new Error('llm-deepseek: inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes') + } const imageOffloadCountQuantum = config.imageOffloadCountQuantum ?? DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM if (!Number.isSafeInteger(imageOffloadCountQuantum) || imageOffloadCountQuantum <= 0) { throw new Error('llm-deepseek: imageOffloadCountQuantum must be a positive safe integer') @@ -301,6 +328,17 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro if (imageOffloadCountQuantum > maxImagesPerRequest) { throw new Error('llm-deepseek: imageOffloadCountQuantum must not exceed maxImagesPerRequest') } + const filesApiTimeoutMs = config.filesApiTimeoutMs ?? DEFAULT_FILES_API_TIMEOUT_MS + if (!Number.isFinite(filesApiTimeoutMs) + || filesApiTimeoutMs <= 0 + || filesApiTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-deepseek: filesApiTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + if (filesApiTimeoutMs >= streamIdleTimeoutMs) { + throw new Error('llm-deepseek: filesApiTimeoutMs must be below streamIdleTimeoutMs') + } const fileExpiresAfterSeconds = config.fileExpiresAfterSeconds ?? DEFAULT_FILE_EXPIRY_SECONDS if (!Number.isSafeInteger(fileExpiresAfterSeconds) || fileExpiresAfterSeconds < 3_600 @@ -333,9 +371,12 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro models: resolveModels(config.models), streamIdleTimeoutMs, maxRequestFilesBytes, + maxInlineRequestImageBytes, maxImagesPerRequest, imageOffloadByteQuantum, + inlineImageOffloadByteQuantum, imageOffloadCountQuantum, + filesApiTimeoutMs, filePolicy: { expiresAfterSeconds: fileExpiresAfterSeconds, refreshMarginSeconds: fileRefreshMarginSeconds, diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 4ac6280cd4..3b22967d96 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,7 +1,7 @@ /** * Serialize harness messages into DeepSeek chat completions. Text-only * requests retain string user content; the image path resolves durable - * attachments into ordered Files API parts. Tool-result images follow their + * attachments into ordered file-id or inline parts. Tool-result images follow their * string-only tool messages in a separate user message. * @module dsh-llm-deepseek/serialize */ @@ -10,7 +10,7 @@ import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImage import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { - WireFileContentPart, + WireImageContentPart, WireMessage, WireRequest, WireTextContentPart, @@ -29,21 +29,30 @@ interface ResolvedThinking { reasoningEffort?: 'low' | 'high' | 'max' } +/** Provider representation for every retained image in one request. */ +export type ImageRequestRepresentation = + | { + kind: 'file' + /** Resolve a retained request version to a reusable DeepSeek file id. */ + resolveFileId: ( + version: RequestImageAttachment, + block: Extract, + location: ImageWireLocation, + ) => Promise + } + | { kind: 'base64' } + /** Dependencies required only when the request contains image input. */ export interface ImageSerializationOptions { - /** Resolve a retained request version to a reusable DeepSeek file id. */ - resolveFileId: ( - version: RequestImageAttachment, - block: Extract, - location: ImageWireLocation, - ) => Promise - /** Request versions prepared for the conservatively retained masters, keyed by attachment id. */ + /** One representation used for every retained image in this request. */ + representation: ImageRequestRepresentation + /** Request versions prepared for the conservatively retained normalized attachments, keyed by attachment id. */ requestImages: ReadonlyMap - /** Positive bound on accumulated referenced image bytes. */ - maxRequestFilesBytes: number - /** Maximum referenced images in one request. */ + /** Positive bound on accumulated represented image bytes. */ + maxRequestImageBytes: number + /** Maximum represented images in one request. */ maxImagesPerRequest?: number - /** Raw-byte removal step applied after the request exceeds its byte bound. */ + /** Represented-byte removal step applied after the request exceeds its byte bound. */ byteQuantum?: number /** Image-count removal step applied after the request exceeds its count bound. */ countQuantum?: number @@ -125,13 +134,13 @@ function imageHandle( } } -/** Resolve one durable image into its descriptor and transient DeepSeek file-id part. */ +/** Resolve one durable image into its descriptor and transient DeepSeek image part. */ async function imageParts( block: Extract, images: ImageSerializationOptions, location: ImageWireLocation, precededByContent: boolean, -): Promise<[WireTextContentPart, WireFileContentPart]> { +): Promise<[WireTextContentPart, WireImageContentPart]> { const version = images.requestImages.get(block.attachment.attachmentId) if (version === undefined) { throw new LlmError( @@ -139,10 +148,13 @@ async function imageParts( 'INVALID_REQUEST', ) } - return [ - imageHandle(version, precededByContent), - { type: 'file', file_id: await images.resolveFileId(version, block, location) }, - ] + const image: WireImageContentPart = images.representation.kind === 'file' + ? { type: 'file', file_id: await images.representation.resolveFileId(version, block, location) } + : { + type: 'image_url', + image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, + } + return [imageHandle(version, precededByContent), image] } /** Convert user or nested tool-result blocks into ordered wire parts. */ @@ -177,7 +189,7 @@ async function contentParts( function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { const text: string[] = [] for (const part of parts) { - if (part.type === 'file') return [...parts] + if (part.type !== 'text') return [...parts] text.push(part.text) } return text.join('') @@ -263,7 +275,7 @@ export function serializeMessages(messages: Message[]): WireMessage[] { * Consecutive tool results keep string `tool` messages and share one following * user message containing their images. * @param messages - transient request history after request-size offloading. - * @param images - prepared request versions and reusable provider file-id resolver. + * @param images - prepared request versions, one provider representation, and its budget. * @returns ordered DeepSeek wire messages. */ export async function serializeMessagesWithImages( @@ -272,7 +284,7 @@ export async function serializeMessagesWithImages( ): Promise { assertSupportedImageRoles(messages) const wire: WireMessage[] = [] - let pendingToolImages: WireFileContentPart[] = [] + let pendingToolImages: WireImageContentPart[] = [] const flushToolImages = (): void => { if (pendingToolImages.length === 0) return wire.push({ @@ -309,14 +321,14 @@ export async function serializeMessagesWithImages( } for (const result of toolResults) { const parts = await contentParts(result.content, images, messageIndex + 1, nextImage) - const fileParts = parts.filter((part): part is WireFileContentPart => part.type === 'file') + const imageParts = parts.filter((part): part is WireImageContentPart => part.type !== 'text') const text = parts.filter(part => part.type === 'text').map(part => part.text).join('') wire.push({ role: 'tool', tool_call_id: result.toolCallId, content: text || '(no output)', }) - pendingToolImages.push(...fileParts) + pendingToolImages.push(...imageParts) } } flushToolImages() @@ -378,7 +390,7 @@ export function serializeRequest( /** * Build one image-capable request while keeping durable bytes out of session * messages. Oversized oldest images become deterministic text after their - * exact request-version byte lengths are known and before provider upload. + * exact request-version byte lengths are known and before provider serialization. * @param options - harness request containing image-capable user content. * @param images - attachment resolver, request bound, and cancellation. * @param defaults - adapter-level thinking defaults. @@ -391,7 +403,7 @@ export async function serializeRequestWithImages( ): Promise { assertSupportedImageRoles(options.messages) const requestMessages = offloadRequestImagesWithPolicy(options.messages, { - representation: 'raw', + representation: images.representation.kind === 'file' ? 'raw' : 'base64', byteLength: (ref) => { const version = images.requestImages.get(ref.attachmentId) if (version === undefined) { @@ -399,7 +411,7 @@ export async function serializeRequestWithImages( } return version.bytes }, - maxBytes: images.maxRequestFilesBytes, + maxBytes: images.maxRequestImageBytes, ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 54f39b095b..f5dd5df0aa 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -47,8 +47,17 @@ export interface WireFileContentPart { file_id: string } +/** Inline base64 data URL inside a multimodal user message. */ +export interface WireImageUrlContentPart { + type: 'image_url' + image_url: { url: string } +} + +/** One image representation accepted by a multimodal user message. */ +export type WireImageContentPart = WireFileContentPart | WireImageUrlContentPart + /** Ordered input part accepted by a multimodal user message. */ -export type WireUserContentPart = WireTextContentPart | WireFileContentPart +export type WireUserContentPart = WireTextContentPart | WireImageContentPart /** User-role message: text-only string or ordered multimodal input. */ export interface WireUserMessage { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index baac1ee39e..5ef87231bb 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -8,6 +8,7 @@ import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import LlmRuntime, { CallId, createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, + LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, @@ -52,6 +53,7 @@ async function harness(baseURL: string, config: object = {}) { function adapterOf( config: Partial & { apiKey?: string } = {}, attachments?: AttachmentStore, + files?: LlmDeepSeek.DeepSeekFileStore, ): DeepSeekAdapter { const { apiKey, ...rest } = config return new DeepSeekAdapter({ @@ -59,6 +61,7 @@ function adapterOf( resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), resolveUserId: () => TEST_USER_ID, resolveAttachments: () => attachments, + ...files === undefined ? {} : { resolveFiles: () => files }, }) } @@ -102,6 +105,32 @@ function attachmentStoreOf( } } +function fileStoreOf( + implementation: (...args: Parameters) => ReturnType, +) { + const ensureUploaded = vi.fn(implementation) + const invalidate = vi.fn(() => Promise.resolve()) + return { + store: { ensureUploaded, invalidate } as unknown as LlmDeepSeek.DeepSeekFileStore, + ensureUploaded, + invalidate, + } +} + +function fileReference(fileId: string): Awaited> { + return { + record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, + uploaded: true, + } as Awaited> +} + +function successfulSseResponse(): Response { + return new Response(textEvents.map(event => `data: ${event}\n\n`).join(''), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) +} + describe('request image policy', () => { it.each([ [ @@ -199,6 +228,188 @@ describe('DeepSeekAdapter against a mock server', () => { expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }]) }) + it('falls back to one all-base64 request when Files API resolution fails', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file_id') + expect(files.ensureUploaded).toHaveBeenCalledTimes(1) + }) + + it('reduces base64 fallback history from the configured high watermark to its half-size quantum', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + maxInlineRequestImageBytes: 80, + inlineImageOffloadByteQuantum: 40, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + const body = JSON.stringify(server.requests[0]) + expect(body.match(/older images are omitted first/g)).toHaveLength(11) + expect(body.match(/"type":"image_url"/g)).toHaveLength(10) + }) + + it('discards partially resolved file ids and falls back with every retained image inline', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-partial')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'TRANSPORT')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file-api-partial') + }) + + it('falls back after the configured Files API deadline without aborting chat', async () => { + vi.useFakeTimers() + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error('files operation aborted')) + }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulSseResponse()) + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + filesApiTimeoutMs: 50, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + await started.promise + await vi.advanceTimersByTimeAsync(50) + await pending + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(String(fetchSpy.mock.calls[0]?.[1]?.body)).toContain('image_url') + fetchSpy.mockRestore() + }) + + it('does not turn caller cancellation during file resolution into base64 fallback', async () => { + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch') + const controller = new AbortController() + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + signal: controller.signal, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + await started.promise + controller.abort() + + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('does not retry a generic chat failure through base64 fallback', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'chat unavailable' } }), + }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.resolve(fileReference('file-api-ready'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'SERVER', message: 'chat unavailable' }) + + expect(server.requests).toHaveLength(1) + expect(JSON.stringify(server.requests[0])).toContain('file-api-ready') + expect(JSON.stringify(server.requests[0])).not.toContain('image_url') + }) + it('does not prepare an old image removed by request offload', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } @@ -454,6 +665,40 @@ describe('DeepSeekAdapter against a mock server', () => { expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) }) + it('uses inline fallback when stale-id recovery cannot resolve a replacement file', async () => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-stale expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-stale')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'SERVER')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(files.invalidate).toHaveBeenCalledTimes(1) + expect(server.requests).toHaveLength(2) + expect(JSON.stringify(server.requests[0])).toContain('file-api-stale') + expect(JSON.stringify(server.requests[1])).toContain('image_url') + }) + it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { const secondRef: ImageAttachmentRef = { ...imageRef, @@ -1150,7 +1395,11 @@ describe('DeepSeekAdapter against a mock server', () => { }) return Promise.resolve(new Response(body, { status: 200 })) }) - const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + filesApiTimeoutMs: 50, + streamIdleTimeoutMs: 100, + }) try { const drain = (async () => { for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } @@ -1181,7 +1430,11 @@ describe('DeepSeekAdapter against a mock server', () => { }) return Promise.resolve(new Response(body, { status: 200 })) }) - const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + filesApiTimeoutMs: 50, + streamIdleTimeoutMs: 100, + }) try { const chunks: string[] = [] const drain = (async () => { @@ -1573,6 +1826,10 @@ describe('plugin registration and config', () => { maxRequestFilesBytes: 10, imageOffloadByteQuantum: 11, })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) + expect(() => resolveAdapterOptions({ + maxInlineRequestImageBytes: 10, + inlineImageOffloadByteQuantum: 11, + })).toThrow(/inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes/) expect(() => resolveAdapterOptions({ maxImagesPerRequest: 10, imageOffloadCountQuantum: 11, @@ -1584,6 +1841,8 @@ describe('plugin registration and config', () => { ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', 0, /inlineImageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /inlineImageOffloadByteQuantum must be a positive safe integer/], ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], @@ -1612,6 +1871,22 @@ describe('plugin registration and config', () => { }, ) + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid inline request image bound %s', + async (maxInlineRequestImageBytes) => { + expect(() => resolveAdapterOptions({ maxInlineRequestImageBytes })) + .toThrow(/maxInlineRequestImageBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxInlineRequestImageBytes, + })).rejects.toThrow(/maxInlineRequestImageBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') @@ -1753,6 +2028,26 @@ describe('plugin registration and config', () => { })).rejects.toThrow(/streamIdleTimeoutMs/) }) + it('rejects invalid Files API timeout bounds for direct and plugin composition', async () => { + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/filesApiTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/filesApiTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: 0, + })).rejects.toThrow(/filesApiTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/filesApiTimeoutMs/) + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) + .toThrow(/filesApiTimeoutMs must be below streamIdleTimeoutMs/) + }) + it('rejects invalid nested retryPolicy before registering the provider', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 547713a74c..968ceabdaf 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -11,6 +11,8 @@ import { } from '../src/serialize.ts' import type { ImageSerializationOptions } from '../src/serialize.ts' +type FileResolver = Extract['resolveFileId'] + function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } } @@ -32,7 +34,7 @@ function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAtta } function fileResolver(id = 'file-api-image') { - return vi.fn(() => Promise.resolve(id)) + return vi.fn(() => Promise.resolve(id)) } function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { @@ -53,13 +55,26 @@ function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { function imageOptions( refs: readonly ImageAttachmentRef[], - resolveFileId: ImageSerializationOptions['resolveFileId'] = fileResolver(), - maxRequestFilesBytes = 20 * 1024 * 1024, + resolveFileId: FileResolver = fileResolver(), + maxRequestImageBytes = 20 * 1024 * 1024, ) { return { - resolveFileId, + representation: { kind: 'file' as const, resolveFileId }, requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), - maxRequestFilesBytes, + maxRequestImageBytes, + } +} + +function inlineImageOptions( + refs: readonly ImageAttachmentRef[], + maxRequestImageBytes = 20 * 1024 * 1024, + byteQuantum = 10 * 1024 * 1024, +): ImageSerializationOptions { + return { + representation: { kind: 'base64' }, + requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), + maxRequestImageBytes, + byteQuantum, } } @@ -359,6 +374,30 @@ describe('image serialization', () => { }]) }) + it.each([ + ['image/png', 'data:image/png;base64,AAAA'], + ['image/jpeg', 'data:image/jpeg;base64,AAAA'], + ['image/webp', 'data:image/webp;base64,AAAA'], + ['image/gif', 'data:image/gif;base64,AAAA'], + ] as const)('serializes every retained %s request version as an inline data URL', async (mediaType, url) => { + const ref = imageRef(mediaType) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), inlineImageOptions([ref])) + + expect(wire.messages).toEqual([{ + role: 'user', + content: [ + { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, + { type: 'image_url', image_url: { url } }, + ], + }]) + }) + it('gives image-only input a stable handle and request dimensions', async () => { const ref = imageRef() const wire = await serializeRequestWithImages(request({ @@ -548,6 +587,21 @@ describe('image serialization', () => { expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ attachment: { mediaType: 'image/jpeg' } }) }) + it('drops base64 history from a 20-unit high watermark to a 10-unit low watermark', async () => { + const ref = imageRef('image/png', 3) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: ref })), + source: { kind: 'plugin', plugin: 'test' }, + })], + }), inlineImageOptions([ref], 80, 40)) + + const content = wire.messages[0]?.content + expect(JSON.stringify(content).match(/older images are omitted first/g)).toHaveLength(11) + expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) + }) + it('rejects an unprepared image while computing exact request bytes', async () => { const ref = imageRef() await expect(serializeRequestWithImages(request({ From d618bfebb4411b5af36e4f9203bd0457a962d496 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 18:34:16 +0800 Subject: [PATCH 062/248] fix(deepseek): decouple files and stream timeouts --- ...2026-08-21-deepseek-files-inline-fallback.i18n.yaml | 4 ++-- .../2026-08-21-deepseek-files-inline-fallback.md | 4 ++-- .../2026-08-21-deepseek-files-inline-fallback.zh.md | 4 ++-- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 4 ++-- packages/llm/llm-deepseek/README.zh.md | 4 ++-- packages/llm/llm-deepseek/src/index.ts | 3 --- packages/llm/llm-deepseek/tests/adapter.spec.ts | 10 ++++------ 8 files changed, 16 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml index ed4af5577d..c4f148394e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.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 .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md -2026-08-21-deepseek-files-inline-fallback.md: c58b3e2257b426f1b5df8a4d6952e890a2bd2982 -2026-08-21-deepseek-files-inline-fallback.zh.md: 34625c6250d52a73ccaac3e33adbd2ed099aab5b +2026-08-21-deepseek-files-inline-fallback.md: 7442089038e2cf47f37661c0f098054d03f67aef +2026-08-21-deepseek-files-inline-fallback.zh.md: 0534514f9bc52f7871f43c288466643cebc7d69c diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md index c58b3e2257..7442089038 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md @@ -10,7 +10,7 @@ The direct DeepSeek vision route uses provider file ids so repeated requests do ## Decision -Files remains the preferred transport. Each request-image file resolution has the configurable `filesApiTimeoutMs` deadline, one minute by default and always below `streamIdleTimeoutMs`. Successful resolutions refresh the outer idle watchdog. Caller cancellation and the outer stream deadline remain terminal outcomes. +Files remains the preferred transport. Each request-image file resolution has the configurable `filesApiTimeoutMs` deadline, one minute by default. The stream idle deadline defaults to five minutes, so the Files deadline normally leaves time for inline fallback. A deployment may configure the stream idle deadline to expire first. Successful resolutions refresh the outer idle watchdog. Caller cancellation and the outer stream deadline remain terminal outcomes. A file resolution failure discards the transient file parts assembled for that chat attempt and rebuilds the complete image request with base64 data URLs. Every retained image uses the already prepared deterministic `RequestImageAttachment`; the fallback performs no additional decode, resize, or encode, and a chat request never mixes file ids with inline images. Upload mappings committed before a later image fails remain available to later requests. The next request tries Files again, so recovery requires no process-wide outage state. @@ -30,7 +30,7 @@ Provider chat errors keep their existing classifications. A stale file id is inv ## Verification -Serializer tests cover file and data-URL representations over the same request versions, all supported media types, tool-result placement, and 20-to-10 base64 offload. Adapter tests cover immediate resolution failure, failure after a partial set of file ids, deadline-triggered fallback, stale-id replacement failure, all-inline request bodies, caller cancellation without fallback, and generic chat failure without a transport switch. Configuration tests cover both inline bounds and the Files deadline relationship. +Serializer tests cover file and data-URL representations over the same request versions, all supported media types, tool-result placement, and 20-to-10 base64 offload. Adapter tests cover immediate resolution failure, failure after a partial set of file ids, deadline-triggered fallback, stale-id replacement failure, all-inline request bodies, caller cancellation without fallback, and generic chat failure without a transport switch. Configuration tests cover both inline bounds and independent Files and stream idle deadlines. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md index 34625c6250..0534514f9b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md @@ -10,7 +10,7 @@ DeepSeek 官方视觉路由使用提供方文件 ID,使重复请求不必再 ## Decision -Files 仍是首选传输方式。每张请求图片的文件解析都有可配置的 `filesApiTimeoutMs` 时限,默认一分钟,且始终小于 `streamIdleTimeoutMs`。每次成功解析都会刷新外层 idle watchdog。调用方取消和外层流时限仍直接终止请求。 +Files 仍是首选传输方式。每张请求图片的文件解析都有可配置的 `filesApiTimeoutMs` 时限,默认一分钟。stream idle 时限默认为五分钟,因此 Files 时限通常会为内联回退留出时间。部署也可以把 stream idle 时限设得更短,让它先终止请求。每次成功解析都会刷新外层 idle watchdog。调用方取消和外层流时限仍直接终止请求。 文件解析失败后,适配器会丢弃为该次 chat 尝试组装的临时文件块,并用 base64 data URL 重新组装完整图片请求。每张保留图片都复用已经准备好的确定性 `RequestImageAttachment`;回退不会再次解码、缩放或编码,同一个 chat 请求也不会混用 file ID 和内联图片。较早图片在后续图片失败前已经提交的上传映射会保留,供之后请求使用。下一次请求会重新尝试 Files,因此不需要保存进程级故障状态。 @@ -30,7 +30,7 @@ Files 仍是首选传输方式。每张请求图片的文件解析都有可配 ## Verification -序列化测试覆盖相同请求版本的文件和 data URL 表示、全部支持的媒体类型、工具结果位置,以及 20MiB 到 10MiB 的 base64 offload。适配器测试覆盖立即解析失败、部分 file ID 成功后的失败、时限触发的回退、失效 ID 替换失败、全内联请求体、调用方取消时不回退,以及普通 chat 错误不切换传输方式。配置测试覆盖两项内联预算和 Files 时限关系。 +序列化测试覆盖相同请求版本的文件和 data URL 表示、全部支持的媒体类型、工具结果位置,以及 20MiB 到 10MiB 的 base64 offload。适配器测试覆盖立即解析失败、部分 file ID 成功后的失败、时限触发的回退、失效 ID 替换失败、全内联请求体、调用方取消时不回退,以及普通 chat 错误不切换传输方式。配置测试覆盖两项内联预算,以及相互独立的 Files 和 stream idle 时限。 ## Consequences diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index e254c6267d..e434fefafc 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: 7a22955565027b30677e46a80a8b719bc7e61917 -README.zh.md: db1669509956d651dcb8948e1191a17cf9a0bfee +README.md: 8a62b7b587323de152ea3322ce310d48a41247cc +README.zh.md: 009732f4256c49d7ae8d702c41df532236f77c5f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7a22955565..8a62b7b587 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -26,7 +26,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps inlineImageOffloadByteQuantum: 10485760 # fallback removal advances in 10 MiB steps imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps - filesApiTimeoutMs: 60000 # per-image Files resolution deadline; below streamIdleTimeoutMs + filesApiTimeoutMs: 60000 # per-image Files resolution deadline; one-minute default fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -58,7 +58,7 @@ An image-capable catalog entry declares `inputModalities: [text, image]` and may Inline fallback has an independent base64 budget. `maxInlineRequestImageBytes` defaults to 20MiB and `inlineImageOffloadByteQuantum` to 10MiB, so a history of 21 one-megabyte base64 payloads removes the oldest 11 and retains 10MiB. The calculation uses base64-expanded lengths. The prepared request versions are reused byte-for-byte; fallback does not decode or compress an image again. Successful mappings created before a later image fails remain indexed for future requests. -Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the normalized attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline fallback refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload. File resolution, including local index access and remote upload, has a per-image one-minute deadline by default; it must remain below `streamIdleTimeoutMs`. Each successful resolution refreshes the outer idle watchdog. Any resolution failure switches that request to inline mode, while explicit public file-management operations continue to report their own failures. +Uploaded ids are indexed below `DSH_HOME` by endpoint/API-key scope and request `variantId`. The variant covers the normalized attachment id, transform version, route pixel and byte budgets, and encoder parameters, so Files API and inline fallback refer to the same deterministic bytes. Uploads request a seven-day lifetime by default and store the server's `expires_at`. A local mapping with no more than one hour remaining is replaced before use; the adapter does not retrieve every remote file before chat. If chat reports expired, deleted, missing, or invalid file ids and names one or more ids used by the request, the adapter removes exactly those mappings. If the provider identifies stale file state without naming an id, it removes every file mapping used by that chat attempt. It then uploads the affected request versions again and retries chat once. A second stale-file rejection clears the mappings identified by that response and is returned without a third chat attempt. An upload response without a complete file object, matching byte count, and `expires_at` is never indexed; a later request therefore uploads again instead of trusting inconsistent local state. A malformed local upload index is treated as an empty cache and replaced by the next successful upload. File resolution, including local index access and remote upload, has a per-image one-minute deadline by default. The default five-minute stream idle deadline therefore leaves time for inline fallback; a deployment may configure a shorter stream idle deadline when it wants that outer deadline to terminate the request first. Each successful resolution refreshes the outer idle watchdog. Any resolution failure switches that request to inline mode, while explicit public file-management operations continue to report their own failures. Concurrent resolution of one scoped `variantId` shares one Files upload with waiter-local cancellation. One quota upload failure first paginates and collects the configured number of oldest `dsh-` files, then deletes that set before one upload retry. `DeepSeekFilesClient.delete`, `DeepSeekFileStore.release`, and `releaseAll` expose explicit remote-space reclamation. The current provider limits represented by this package are 128MiB per Files upload, 32MiB per chat-referenced image, 10,000 stored files, and 25GiB per API key; the default 1MiB request version remains below the two per-file limits. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index db16695099..009732f425 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -26,7 +26,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: imageOffloadByteQuantum: 67108864 # oldest-image removal advances in 64 MiB steps inlineImageOffloadByteQuantum: 10485760 # fallback removal advances in 10 MiB steps imageOffloadCountQuantum: 20 # count overflow advances in 20-image steps - filesApiTimeoutMs: 60000 # per-image Files resolution deadline; below streamIdleTimeoutMs + filesApiTimeoutMs: 60000 # per-image Files resolution deadline; one-minute default fileExpiresAfterSeconds: 604800 # uploaded image lifetime; 1 hour to 30 days fileRefreshMarginSeconds: 3600 # replace ids with less lifetime remaining fileQuotaCleanupBatch: 100 # oldest harness-owned files deleted before one quota retry @@ -58,7 +58,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 内联回退使用独立的 base64 预算。`maxInlineRequestImageBytes` 默认为 20MiB,`inlineImageOffloadByteQuantum` 默认为 10MiB,因此由 21 个 1MiB base64 负载组成的历史会移除最旧的 11 个并保留 10MiB。计算使用 base64 膨胀后的长度。系统逐字节复用已经准备好的请求版本;回退不会再次解码或压缩图片。前面图片已经成功写入的上传映射会保留,供后续请求复用。 -上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和内联回退引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换。文件解析包括本地索引访问和远端上传,默认每张图片的时限为一分钟,且必须小于 `streamIdleTimeoutMs`。每次成功解析都会刷新外层 idle watchdog。任何解析失败都会把该请求切换到内联模式;显式公共文件管理操作仍会报告自身错误。 +上传 ID 按端点和 API key 作用域以及请求 `variantId` 记录在 `DSH_HOME` 下。变体身份覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及编码参数,因此 Files API 和内联回退引用同一份确定性字节。上传默认请求 7 天有效期,并保存服务端返回的 `expires_at`。本地映射剩余时间不超过一小时时会在使用前替换;适配器不会在每次 chat 前查询远端文件。如果 chat 报告文件 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出 ID,适配器会删除该次 chat 使用的全部文件映射。随后重新上传受影响的请求版本,并重试一次 chat。第二次 chat 仍报告文件失效时,适配器会按该响应清理映射并返回错误,不会发起第三次 chat。上传响应若没有完整文件对象、匹配的字节数和 `expires_at`,就不会写入索引;后续请求会再次上传,而不是信任不一致的本地状态。本地上传索引格式损坏时按空缓存处理,并由下一次成功上传替换。文件解析包括本地索引访问和远端上传,默认每张图片的时限为一分钟。默认的 stream idle 时限为五分钟,因此通常有时间执行内联回退;部署可以设置更短的 stream idle 时限,让外层时限先终止请求。每次成功解析都会刷新外层 idle watchdog。任何解析失败都会把该请求切换到内联模式;显式公共文件管理操作仍会报告自身错误。 同一作用域和 `variantId` 的并发解析共享一次 Files 上传,每个等待方可以单独取消。一次上传配额错误会先分页收集配置数量的最旧 `dsh-` 文件,再删除这些文件并重试一次上传。`DeepSeekFilesClient.delete`、`DeepSeekFileStore.release` 和 `releaseAll` 提供主动远端空间回收。本包记录的当前提供方限制为 Files 单次上传 128MiB、chat 单图引用 32MiB、每个 API key 最多 10,000 个文件和 25GiB;默认 1MiB 请求版本低于两个单文件上限。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index e8632c22da..3af0236d29 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -336,9 +336,6 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro `llm-deepseek: filesApiTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - if (filesApiTimeoutMs >= streamIdleTimeoutMs) { - throw new Error('llm-deepseek: filesApiTimeoutMs must be below streamIdleTimeoutMs') - } const fileExpiresAfterSeconds = config.fileExpiresAfterSeconds ?? DEFAULT_FILE_EXPIRY_SECONDS if (!Number.isSafeInteger(fileExpiresAfterSeconds) || fileExpiresAfterSeconds < 3_600 diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5ef87231bb..085b35063a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -348,7 +348,7 @@ describe('DeepSeekAdapter against a mock server', () => { await pending expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(String(fetchSpy.mock.calls[0]?.[1]?.body)).toContain('image_url') + expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('image_url')) fetchSpy.mockRestore() }) @@ -1397,7 +1397,6 @@ describe('DeepSeekAdapter against a mock server', () => { }) const adapter = adapterOf({ baseURL: 'https://example.invalid', - filesApiTimeoutMs: 50, streamIdleTimeoutMs: 100, }) try { @@ -1432,7 +1431,6 @@ describe('DeepSeekAdapter against a mock server', () => { }) const adapter = adapterOf({ baseURL: 'https://example.invalid', - filesApiTimeoutMs: 50, streamIdleTimeoutMs: 100, }) try { @@ -2028,7 +2026,7 @@ describe('plugin registration and config', () => { })).rejects.toThrow(/streamIdleTimeoutMs/) }) - it('rejects invalid Files API timeout bounds for direct and plugin composition', async () => { + it('validates Files API timeout bounds independently of the stream idle deadline', async () => { expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) .toThrow(/filesApiTimeoutMs.*positive finite/) expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) @@ -2044,8 +2042,8 @@ describe('plugin registration and config', () => { baseURL: 'http://127.0.0.1:1', filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, })).rejects.toThrow(/filesApiTimeoutMs/) - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) - .toThrow(/filesApiTimeoutMs must be below streamIdleTimeoutMs/) + expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) + .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) }) it('rejects invalid nested retryPolicy before registering the provider', async () => { From aa6c361a972c8369148dea7380bb5c21c24e07ec Mon Sep 17 00:00:00 2001 From: imccyu Date: Fri, 21 Aug 2026 19:48:58 +0800 Subject: [PATCH 063/248] release(dsh): 0.1.1-rc.2 --- apps/cli/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/runtime/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-attachment/package.json | 2 +- packages/client/ui-brand-official/package.json | 2 +- packages/client/ui-commands/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-directory-picker-browse/package.json | 2 +- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-input-trigger/package.json | 2 +- packages/client/ui-jobs/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-message-feedback/package.json | 2 +- packages/client/ui-model-selection/package.json | 2 +- packages/client/ui-permission-presets/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-reference/package.json | 2 +- packages/client/ui-renderer/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings-models/package.json | 2 +- packages/client/ui-settings-plugin-inventory/package.json | 2 +- packages/client/ui-settings-plugins/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-user-questions/package.json | 2 +- packages/client/ui-workflow-run/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-python/package.json | 2 +- packages/code-runtime/code-runtime-worker-thread/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compaction/command-compact/package.json | 2 +- packages/compaction/compaction-basic/package.json | 2 +- packages/compaction/compaction-tool-result-pruner/package.json | 2 +- packages/compaction/compaction/package.json | 2 +- packages/context/agent-instructions/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-presentation/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/authorization/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/examples/acp-demo/package.json | 2 +- packages/examples/agent-spine-demo/package.json | 2 +- packages/examples/jsonrpc-demo/package.json | 2 +- packages/experimental/agent-team/package.json | 2 +- packages/experimental/tool-agent-team/package.json | 2 +- packages/extensions/cordis-client-runner/package.json | 2 +- packages/extensions/cordis-host-runner/package.json | 2 +- packages/extensions/tool-cordis/package.json | 2 +- packages/extensions/ui-cordis/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/feedback/message-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-observation-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-round-driver/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-reminder/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude-code/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/apiproxy/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/plugin-inventory/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/identity/anonymous-user-id/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission-presets/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-questions/package.json | 2 +- packages/jobs/jobs-local/package.json | 2 +- packages/jobs/jobs/package.json | 2 +- packages/jobs/tool-jobs/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-stdio/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/runtime-diagnostics/invariants/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/schedule/schedule/package.json | 2 +- packages/sdk/client/package.json | 2 +- packages/sdk/protocol/package.json | 2 +- packages/sdk/server/package.json | 2 +- packages/session-query/session-log-export/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence-sqlite/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-stats/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-prompts-llm/package.json | 2 +- packages/session/session-title-first-prompt-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/settings/settings-file/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/shell/bash-local/package.json | 2 +- packages/shell/bash-sandbox/package.json | 2 +- packages/shell/pwsh-local/package.json | 2 +- packages/shell/pwsh-sandbox/package.json | 2 +- packages/shell/shell-env/package.json | 2 +- packages/shell/shell/package.json | 2 +- packages/shell/tool-bash-persistent/package.json | 2 +- packages/shell/tool-bash/package.json | 2 +- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-filesystem/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork-in-process/package.json | 2 +- packages/subagent/subagent-in-process-driver/package.json | 2 +- packages/subagent/subagent-spawn-in-process/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent-report/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal/package.json | 2 +- packages/terminal/tool-terminal/package.json | 2 +- packages/test-support/acp-snapshot/package.json | 2 +- packages/test-support/agent-loop-testkit/package.json | 2 +- packages/test-support/client-runtime/package.json | 2 +- packages/test-support/llm-mock-server/package.json | 2 +- packages/test-support/llm-replay/package.json | 2 +- packages/test-support/loader-smoke/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/protocol/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/home-paths/package.json | 2 +- packages/util/launch-environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/output-retention/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-http/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-worker-thread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 230 files changed, 230 insertions(+), 230 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index fa75d3e0a8..eeeb48e79a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/apps/web/package.json b/apps/web/package.json index 3474056428..bef80ee0a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json index d963c3900e..391c93d938 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 2c059f4a16..fa9eaf8ded 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index a9051c82fe..775c95e999 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 5fa3c1145f..102344ba91 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly and Host Agent/Session lookup policy", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index f6e2d6087d..194112be29 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index e8ff44fdcc..1abd03e3e0 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index a8b8cbf2f0..0b4b1d7a74 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 26fc61ca5e..60c63d4ec4 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 8b7058446f..2096a64b75 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index c6b84167d5..d8c97032c1 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 920bd6d051..530192b0e4 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index c7953dcf0a..da33c138ab 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 025ba803d3..3ad65222f5 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 9cc88d924b..8ab9691fc9 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index dd588f304a..62926fa35b 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 24a869734c..d7fde8c598 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-runtime", "description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 10540cb10b..e7bb8e1cce 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index e334333724..9f25dc5421 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input and message-image slots", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index e6f822fa5a..e9cf326b3f 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index bc0e8ad643..649bb29d57 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index fe0f6cdfd5..fb00dd319c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index ebc1613fb8..4035f4616c 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index f0f415f341..3f1ef97ed3 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 986c71a326..48e91703b1 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 91313e8823..27ea4a076c 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 88308808d7..9916dc1de7 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index a274d7e9a7..5f3b41460f 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index a3b3a234b5..3b2bf0da97 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 17698ed591..14034b8478 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 1cfdf8994c..646ddfef0b 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection: the /model popupSelect over session.models / session.selectModel", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 973b2d8cb7..55c6b4203a 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 1775c7e755..d9849f1101 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 987be01362..9410edae05 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 724b3e9650..731b0fd8a0 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index f5de30fe18..b6fbf0a9f7 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 4af223214c..de5fa695d8 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index a59ec457f8..6b7f46dfe8 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index ac458c9d10..c67689bcad 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index e00f83ca80..5c7cf9ceeb 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index e0b99af360..e37334c509 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 471a2ca1c5..b008baa603 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 63c97f1d6c..e04d03aa88 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 26ac6a1dd3..2fc6e4840f 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 382c80ff47..16d497e7ff 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index ecdf1ea1e9..eed01f67ba 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index d3a6cf36bb..1f4411367c 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 3fb783ff77..afb1ebf40b 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 6bad3b32a5..884c005d82 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index d1bcc9af42..9ab72c7270 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 57b6f4b08f..8ce02c90bc 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 68e8cff67d..c7faf9d701 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 8572cd11f4..0cb5b0413d 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 47d34a102c..d655c326bc 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index efa2c09ac8..b107880ba7 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index c17443bc81..4872714c50 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index aeda7d0ae1..8114091eeb 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 34bab8a850..7bed09943a 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 883dd9dc94..e995cd3d16 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 85a20423aa..419b8d5c5e 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 16b7f3f21b..4c2170ac60 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index dd09c6edf3..7d34d416cf 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 3155e50855..7336a6a613 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 8066090d80..d63ddc7ab4 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 29557cc766..e513897388 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 8de3093e0a..6b2b5ef87a 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index aba8169041..ca3961c893 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 03dbf6040c..382c0caf61 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 53dd85239c..115feb18aa 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 4f6cf76df9..67b7fc8cc5 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index ce9c7e5e7f..4e2cc7d75f 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index e318ddc6f7..6b09b326d7 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 048b16826c..aa567e811f 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 386db666fb..c88162f140 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 69592c2b56..5d802e76e2 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index bc44ad9437..13a5f0f794 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 1987e2268e..bfd65b380d 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 86abe01c30..ab88b3e5c5 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index 62ebdeb169..25d98bfe39 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index c5e8b6bf15..6ef3e8a95d 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-demo", "description": "ACP automation server app: agent spine + JSONL persistence + ACP transport, with a JSON-RPC stdio bin", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 2bfe1b942e..2d19e61ff5 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", "description": "The default executor-less/UI-less agent spine with fallback session titles, provider-routed retry, and optional persisted goals", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index b3d3a3cde2..a0b1fbe5d4 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-demo", "description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 555464e5c2..b73a8b998d 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index d7c3b84908..35e8ddcb55 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 6ebcd884c8..3b183d6839 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index a98de65906..6fce78d714 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index c9ecff3f45..6e8428322e 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index d0d2a0cd71..a3742af61e 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 9fc788652b..ecaf45a615 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 75fcaad4b0..ecad54a19b 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 841235bcb4..58d86392e5 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 3e15d32658..35af55cc86 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 28dd30a924..c41b249a59 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index a3e4152400..1d4f51dfd9 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 6356694111..8b265eb6bc 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index db1d8c697c..7214e5fa46 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 35b4585985..a055f01752 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 6c321ae0e1..fe0edc727d 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index e783a29679..eb16656526 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 72ad0008ff..08cc158453 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 8a255aafb6..576b60b235 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 920c538bf8..9bc0631cd7 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index b98dd79ed9..bef6cf1251 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index df64c59dc5..7d65ddb10a 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 801edee222..958a94e4dd 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 2dccaad94d..1308ea38db 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 94e487908c..93a9e92a2c 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-apiproxy", "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 3b494a20b7..6693a4e6de 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 481484b84f..2936081351 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 5b84ded89d..8215d96874 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 77bc74bd42..5be5940ecd 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 629dcf81d0..2fcc012873 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 0dd6b5702a..ebfcf857a6 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 6404959405..a8739fddc0 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 3924d34ec3..35019e6c7b 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index e83a1b9f21..935930386c 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 655da1f675..a9e3313204 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 1682eeb3d0..067115a5d2 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 2549f36d32..5ec59a32d6 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 618ad34ab7..07dafa3341 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index b075e12ae5..117f9d9e99 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index c6038d61c4..c658d2d49f 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 77a88540c9..fc1fc6877c 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index effb77d4c0..18bcb2e953 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 52a6c21a57..0fd59afaa1 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 35aafbbf09..e66c909078 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index b802585520..6bd6fc2df7 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index a70cfd926b..60a21c7a8e 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index b269b2278c..3dca6f9433 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 1df115bd0e..249b8249da 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 560dd4c646..de95b13daa 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 380d5b55bc..e7d218161f 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 9ead831256..07e7d0cf4c 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 1d3b16bad7..95e0144108 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 0bec216d4f..8eb81d930b 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 8afb04aeed..9cbd4110e7 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index d65f224b71..12c948b7c6 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index be50e31dc7..eda4650b47 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 70ae6e22a4..b1d0161963 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 9a3b61a875..3bec839e4d 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 52fd5f58fa..9cf315d544 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 498ede1f0f..24a632c4be 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 5fecdbd8a8..c5bac4b7e7 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index a7a444ad92..f32b4a0147 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 5e96e9d23e..0d5988c0e1 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, "repository": { "type": "git", diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index b37ef38c61..01bd2da609 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 259027ddb6..d39ba2c494 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index a9e2cf52f2..99ae13a209 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index db778d0768..563ddd47d1 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index c63ed3a080..190de373b5 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 57d949b576..a4495d43a9 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-sqlite", "description": "SQLite durable session persistence with physical chunk-row packing", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 6aed227f87..195bb39ef5 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 08a89dd935..826d33ab9d 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index e74e2f5ee6..ca0bbba5a1 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index d1c19a0c3e..4627864028 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 1b1c94cb2f..f5d4b60d88 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index b37a0b6016..f802989e6d 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 991d898246..de26cc91db 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index d9e97b8bd9..86c3ebd34d 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 902a605b79..7c33b3fde6 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index ae623a9ac4..57b3a03244 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 1b0f4a2ee6..3d0d2e460a 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 18e9b23b57..5d3f0b8838 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index b8c4c2f7dd..f26125d911 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index d4f9f544ea..4464609f97 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index fd3a2b2e6e..f76007bd96 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 8d8ed6c763..267a89c4e6 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 747d4abd1a..bd796263d6 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 80f3d76b0a..02d4792c8a 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 3b946d50e3..c66bef18e3 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index e2b60086f3..321e2822d2 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 4e3e186345..b722f12a05 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 690f7043de..2294a955a3 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index aab33e4aab..0f01470e6d 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 981805dab0..4cd30a85c9 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 8f369bc663..04fa4dcd92 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 924b1789cb..798c09ca05 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 72e3975614..44ca42effd 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 4a6982c7e4..fec25041f6 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 3baac54238..4dc57a5b51 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index cae74ea42f..3f4984bce2 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 21e2183a4a..b147083c59 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index eec3232f23..2b96120223 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 610521185b..a4e1135596 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index c3b2887685..5e9805e239 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 5e05a2e689..20dcc8a4ba 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index ed1ffaed3e..06cb026dfd 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 37a0732126..35617aa3b6 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 05aa3cb80d..b7383f45bf 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index b54eccd923..70ffbab72d 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index c977a92bd9..3ea456cf4e 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index e210f1d292..269268dc6a 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index eb540cb76f..9d22d4a93b 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index c84b1b5952..a0ff7ecede 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 1da744d076..9ddab59996 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 3629c04dcd..8dbb8acb6a 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index b66180e38c..274ed88295 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 9738c92f18..cb3dccce08 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index f174cb4d62..7faf010edf 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index ee8437a5c3..c4d4eefe7f 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/acp-snapshot/package.json b/packages/test-support/acp-snapshot/package.json index 7acd538ad7..9a2216fcf4 100644 --- a/packages/test-support/acp-snapshot/package.json +++ b/packages/test-support/acp-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, expected-output normalizers, and suite factory", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 279de21d2d..cba0828f2b 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index c494216aa5..fb39c32177 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index c88c46aa82..eac77fc152 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 48dac25b10..7618322e57 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index f2644a62fe..2772c11be6 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 1e356f269c..54c32c66fc 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 09a73f6fdb..78a18e510f 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 217db56016..8433b81657 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 21be169ba5..12f8283931 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index ab07029d14..b66a41ea7f 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index aad00ab7d3..481ea9246b 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index e8f2322245..fd383c1360 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index f7f8a66b5f..45ccf74b9a 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 712b418a29..18db056476 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 5023b8eeab..285c468c95 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 3256b25f35..491bb5b49c 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 914af99f4a..43f8a36507 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 6dfada11de..751ae9e376 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 7908d9ec51..3dfee71b40 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 5be501acf1..dc1a46bf0f 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index a5b6cb3251..5ff018d22a 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 3ff8568636..467f54299f 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 9137d699f7..c6c3dd5ff3 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 54f2543c7d..980ed904a6 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index e3e2966e26..455d2ee54c 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index ca053b305d..0a3713f7de 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 06bb267e65..17ff2d2959 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index edbec99cc1..6c2fa2782e 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.1-rc.1", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, From f47b1ecac271a74a82ed0b055f1e06f8cd173b6c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:19:07 +0800 Subject: [PATCH 064/248] feat(webworker): browser worker host runtime and the vfs image packer Two private experimental packages run the whole harness tree inside one dedicated Web Worker. dsh-experimental-webworker-runtime owns the in-memory VFS (BigInt stats with per-path identity and strictly increasing mtimes), the CommonJS wrapper loader over a lazily-evaluated builtin table whose shims typecheck against Node's own module types, the postMessage tunnel speaking plain HTTP, the AsyncLocalStorage runtime, and the worker assembly. dsh-experimental-webworker-packer lowers every module body at pack time against the shared wrapper contract, sweeps the profile closure by static reachability, and writes a deterministically gzip-compressed tar the worker inflates through the browser's native DecompressionStream while it downloads. --- THIRD_PARTY_NOTICES.md | 6 + apps/cli/package.json | 5 + knip.json | 17 +- packages/bundle/web-app/src/index.ts | 15 +- packages/bundle/web-app/tests/web-app.spec.ts | 16 +- packages/experimental/README.i18n.yaml | 4 +- packages/experimental/README.md | 2 + packages/experimental/README.zh.md | 2 + .../webworker-packer/README.i18n.yaml | 6 + .../experimental/webworker-packer/README.md | 27 + .../webworker-packer/README.zh.md | 27 + .../webworker-packer/package.json | 54 ++ .../experimental/webworker-packer/src/bin.ts | 57 ++ .../webworker-packer/src/index.ts | 15 + .../webworker-packer/src/invariant.ts | 31 + .../experimental/webworker-packer/src/pack.ts | 575 ++++++++++++++ .../webworker-packer/src/repository.ts | 173 +++++ .../webworker-packer/src/rules.ts | 70 ++ .../webworker-packer/src/transform-image.ts | 26 + .../tests/image-loadable.spec.ts | 138 ++++ .../webworker-packer/tsconfig.json | 24 + .../webworker-packer/tsdown.config.ts | 18 + .../webworker-runtime/README.i18n.yaml | 6 + .../experimental/webworker-runtime/README.md | 33 + .../webworker-runtime/README.zh.md | 33 + .../webworker-runtime/package.json | 64 ++ .../src/client/api-client.ts | 33 + .../src/client/apply-injections.ts | 50 ++ .../webworker-runtime/src/client/client.ts | 342 +++++++++ .../webworker-runtime/src/client/index.ts | 94 +++ .../src/compile/transform.ts | 571 ++++++++++++++ .../webworker-runtime/src/image-layout.ts | 47 ++ .../webworker-runtime/src/index.ts | 44 ++ .../webworker-runtime/src/invariant.ts | 32 + .../webworker-runtime/src/module-proxies.ts | 76 ++ .../src/module-system/module-loader.ts | 407 ++++++++++ .../src/module-system/posix-path.ts | 169 +++++ .../implemented/async_hooks.ts | 406 ++++++++++ .../builtin_modules/implemented/buffer.ts | 28 + .../builtin_modules/implemented/crypto.ts | 123 +++ .../builtin_modules/implemented/events.ts | 156 ++++ .../node/builtin_modules/implemented/fs.ts | 574 ++++++++++++++ .../implemented/fs/promises.ts | 20 + .../node/builtin_modules/implemented/http.ts | 179 +++++ .../builtin_modules/implemented/module.ts | 73 ++ .../node/builtin_modules/implemented/os.ts | 118 +++ .../node/builtin_modules/implemented/path.ts | 396 ++++++++++ .../builtin_modules/implemented/perf_hooks.ts | 27 + .../implemented/timers/promises.ts | 60 ++ .../node/builtin_modules/implemented/url.ts | 73 ++ .../node/builtin_modules/implemented/util.ts | 156 ++++ .../builtin_modules/implemented/util/types.ts | 14 + .../node/builtin_modules/implemented/zlib.ts | 85 +++ .../src/node/builtin_modules/mock/net.ts | 90 +++ .../src/node/builtin_modules/mock/sqlite.ts | 31 + .../src/node/builtin_modules/mock/stream.ts | 38 + .../src/node/builtin_modules/mock/vm.ts | 37 + .../builtin_modules/mock/worker_threads.ts | 50 ++ .../webworker-runtime/src/node/builtins.ts | 122 +++ .../src/node/external_packages/chokidar.ts | 68 ++ .../src/node/external_packages/koffi.ts | 155 ++++ .../node-addon-landlock-run.ts | 31 + .../src/node/external_packages/node-pty.ts | 19 + .../src/node/external_packages/pi-ai.ts | 92 +++ .../external_packages/replaced-externals.ts | 19 + .../src/node/external_packages/ripgrep.ts | 15 + .../src/node/external_packages/sharp.ts | 13 + .../src/node/external_packages/ws.ts | 62 ++ .../src/node/globals/process.ts | 136 ++++ .../src/node/globals/timers.ts | 68 ++ .../src/node/notImplementedFail.ts | 44 ++ .../src/polyfill/async-context/als-runtime.ts | 102 +++ .../async-context/async-context-hooks.ts | 80 ++ .../webworker-runtime/src/storage/active.ts | 28 + .../src/storage/image-gzip.ts | 100 +++ .../webworker-runtime/src/storage/memory.ts | 595 +++++++++++++++ .../webworker-runtime/src/storage/paths.ts | 23 + .../webworker-runtime/src/storage/tar.ts | 136 ++++ .../webworker-runtime/src/storage/types.ts | 115 +++ .../webworker-runtime/src/transport/frames.ts | 123 +++ .../src/transport/synthetic-http.ts | 139 ++++ .../webworker-runtime/src/transport/tunnel.ts | 437 +++++++++++ .../webworker-runtime/src/worker-host.ts | 467 ++++++++++++ .../webworker-runtime/src/worker.ts | 64 ++ .../tests/compile/transform-corpus-check.ts | 437 +++++++++++ .../tests/compile/transform-corpus.spec.ts | 33 + .../tests/compile/transform.spec.ts | 710 ++++++++++++++++++ .../webworker-runtime/tests/log-sink.spec.ts | 86 +++ .../tests/node/builtins-table.spec.ts | 87 +++ .../tests/node/events.spec.ts | 137 ++++ .../webworker-runtime/tests/node/fs.spec.ts | 203 +++++ .../tests/node/http-server.spec.ts | 83 ++ .../tests/node/node-stubs.spec.ts | 206 +++++ .../tests/node/path-diff.spec.ts | 73 ++ .../tests/node/process-shim.spec.ts | 47 ++ .../tests/node/shim-diff.spec.ts | Bin 0 -> 3514 bytes .../tests/node/timers-promises.spec.ts | 55 ++ .../tests/polyfill/als-runtime.spec.ts | 394 ++++++++++ .../tests/polyfill/als-shim.spec.ts | 394 ++++++++++ .../tests/polyfill/als.spec.ts | 82 ++ .../tests/storage/image-gzip.spec.ts | 86 +++ .../tests/storage/memory-vfs.spec.ts | 95 +++ .../tests/storage/tar.spec.ts | 37 + .../tests/transport/tunnel-client.spec.ts | 131 ++++ .../webworker-runtime/tsconfig.json | 36 + .../webworker-runtime/tsdown.config.ts | 80 ++ pnpm-lock.yaml | 333 +++++++- scripts/check-workspace-constraints.ts | 9 +- scripts/publint-all.ts | 23 +- .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 4 + tsconfig.host.json | 1 + vitest.config.ts | 13 + 113 files changed, 13126 insertions(+), 47 deletions(-) create mode 100644 packages/experimental/webworker-packer/README.i18n.yaml create mode 100644 packages/experimental/webworker-packer/README.md create mode 100644 packages/experimental/webworker-packer/README.zh.md create mode 100644 packages/experimental/webworker-packer/package.json create mode 100644 packages/experimental/webworker-packer/src/bin.ts create mode 100644 packages/experimental/webworker-packer/src/index.ts create mode 100644 packages/experimental/webworker-packer/src/invariant.ts create mode 100644 packages/experimental/webworker-packer/src/pack.ts create mode 100644 packages/experimental/webworker-packer/src/repository.ts create mode 100644 packages/experimental/webworker-packer/src/rules.ts create mode 100644 packages/experimental/webworker-packer/src/transform-image.ts create mode 100644 packages/experimental/webworker-packer/tests/image-loadable.spec.ts create mode 100644 packages/experimental/webworker-packer/tsconfig.json create mode 100644 packages/experimental/webworker-packer/tsdown.config.ts create mode 100644 packages/experimental/webworker-runtime/README.i18n.yaml create mode 100644 packages/experimental/webworker-runtime/README.md create mode 100644 packages/experimental/webworker-runtime/README.zh.md create mode 100644 packages/experimental/webworker-runtime/package.json create mode 100644 packages/experimental/webworker-runtime/src/client/api-client.ts create mode 100644 packages/experimental/webworker-runtime/src/client/apply-injections.ts create mode 100644 packages/experimental/webworker-runtime/src/client/client.ts create mode 100644 packages/experimental/webworker-runtime/src/client/index.ts create mode 100644 packages/experimental/webworker-runtime/src/compile/transform.ts create mode 100644 packages/experimental/webworker-runtime/src/image-layout.ts create mode 100644 packages/experimental/webworker-runtime/src/index.ts create mode 100644 packages/experimental/webworker-runtime/src/invariant.ts create mode 100644 packages/experimental/webworker-runtime/src/module-proxies.ts create mode 100644 packages/experimental/webworker-runtime/src/module-system/module-loader.ts create mode 100644 packages/experimental/webworker-runtime/src/module-system/posix-path.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/async_hooks.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/crypto.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/events.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/http.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/os.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/path.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/perf_hooks.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/timers/promises.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/url.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/util.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/util/types.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/zlib.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/net.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/sqlite.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/vm.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/worker_threads.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtins.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/koffi.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/node-pty.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/pi-ai.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/ripgrep.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/sharp.ts create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/ws.ts create mode 100644 packages/experimental/webworker-runtime/src/node/globals/process.ts create mode 100644 packages/experimental/webworker-runtime/src/node/globals/timers.ts create mode 100644 packages/experimental/webworker-runtime/src/node/notImplementedFail.ts create mode 100644 packages/experimental/webworker-runtime/src/polyfill/async-context/als-runtime.ts create mode 100644 packages/experimental/webworker-runtime/src/polyfill/async-context/async-context-hooks.ts create mode 100644 packages/experimental/webworker-runtime/src/storage/active.ts create mode 100644 packages/experimental/webworker-runtime/src/storage/image-gzip.ts create mode 100644 packages/experimental/webworker-runtime/src/storage/memory.ts create mode 100644 packages/experimental/webworker-runtime/src/storage/paths.ts create mode 100644 packages/experimental/webworker-runtime/src/storage/tar.ts create mode 100644 packages/experimental/webworker-runtime/src/storage/types.ts create mode 100644 packages/experimental/webworker-runtime/src/transport/frames.ts create mode 100644 packages/experimental/webworker-runtime/src/transport/synthetic-http.ts create mode 100644 packages/experimental/webworker-runtime/src/transport/tunnel.ts create mode 100644 packages/experimental/webworker-runtime/src/worker-host.ts create mode 100644 packages/experimental/webworker-runtime/src/worker.ts create mode 100644 packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts create mode 100644 packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/compile/transform.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/log-sink.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/events.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/fs.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/http-server.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/path-diff.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/process-shim.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/shim-diff.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/timers-promises.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/polyfill/als-runtime.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/polyfill/als-shim.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/polyfill/als.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/storage/image-gzip.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/storage/tar.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts create mode 100644 packages/experimental/webworker-runtime/tsconfig.json create mode 100644 packages/experimental/webworker-runtime/tsdown.config.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 295eb1868f..bb9ad51cf1 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT | | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | @@ -51,7 +52,10 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | +| [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | +| [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | @@ -136,6 +140,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | @@ -148,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`http-server`](https://github.com/http-party/http-server) | MIT | | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | diff --git a/apps/cli/package.json b/apps/cli/package.json index eeeb48e79a..b3cef32dea 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,6 +18,11 @@ "lib/*.js", "config" ], + "dsh": { + "configTrees": [ + { "mount": "config/agent-presets", "path": "config/agent-presets", "scanRoster": true } + ] + }, "license": "MIT", "dependencies": { "@deepseek-ai/cordis-plugin-hmr": "workspace:^", diff --git a/knip.json b/knip.json index 280d10a1f0..28267ff092 100644 --- a/knip.json +++ b/knip.json @@ -211,6 +211,20 @@ "tests/**/*.ts" ] }, + "packages/experimental/webworker-runtime": { + "entry": [ + "tests/**/*.spec.ts", + "tests/compile/transform-corpus-check.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "ignoreDependencies": [ + "buffer", + "@deepseek-ai/dsh-client-modules" + ] + }, "packages/typert/generator": { "entry": [ "tests/**/*.spec.ts", @@ -611,7 +625,8 @@ "tests/**/*.perf.ts", "tests/**/*.snapshot.ts", "tests/support.ts", - "src/node-module-stub.ts" + "src/node-module-stub.ts", + "src/preview.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 6965310437..79d1e94862 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -13,6 +13,7 @@ import { spawn, type ChildProcess } from 'node:child_process' import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' @@ -159,14 +160,20 @@ function localWebUrl(ctx: Context): string { return `http://${LOOPBACK_HOST}:${String(port)}` } -/** Dist location is workspace knowledge of this bundle: resolved through the frontend package exports, not configured. */ +/** + * Dist location is workspace knowledge of this bundle: anchored on the + * frontend package manifest, not configured. Existence is a request-time + * concern — the fallback owner reads files per request, so a composition + * whose page never reaches the fallback seat (the static worker preview + * ships its own page and carries no dist) boots without one. + */ function resolveDistIndex(): string { const require = createRequire(import.meta.url) try { - return require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') + return join(dirname(require.resolve('@deepseek-ai/dsh-web-frontend/package.json')), 'dist', 'index.html') } catch { - /* v8 ignore next 2 -- reachable only on a checkout without a built dist; the test tree builds it */ - throw new Error('web-app: frontend dist not built; run pnpm run build from the repository root first') + /* v8 ignore next 2 -- reachable only when the frontend package is absent from the checkout */ + throw new Error('web-app: @deepseek-ai/dsh-web-frontend is not resolvable from this composition') } } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 39b9d7ac6b..5639129362 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -286,16 +286,12 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => { - // The production resolver (not the test hook). A built checkout resolves - // the frontend package's index.html; a dist-less one (the CI coverage - // lane runs before any build) must fail with the build hint, never a - // silent fallback. - try { - expect(originalResolve()).toMatch(/dist[/\\]index\.html$/) - } catch (error) { - expect((error as Error).message).toContain('frontend dist not built') - } + it('anchors the dist index on the frontend package manifest without requiring a built dist', () => { + // The production resolver (not the test hook): the anchor resolves on any + // checkout, built or not — dist existence is the fallback owner's + // request-time concern, so a dist-less composition (the static worker + // preview ships its own page) still boots. + expect(originalResolve()).toMatch(/dist[/\\]index\.html$/) }) it.each([ diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index d5ef901778..ba94b89182 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.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 packages/experimental/README.md -README.md: 0e92ebd2bd959ac807830400dd57807b87b1cbe2 -README.zh.md: a1751c39f53bf8f6c0a9c623aba57355f35c2681 +README.md: 43d96d1c539b2ec35d7a818f60270e6d17db54e9 +README.zh.md: 27bb4d73b0d8baa4614e79abbf20a1f988f8652c diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 0e92ebd2bd..43d96d1c53 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -8,5 +8,7 @@ This group contains prototypes and internal-only Cordis plugins that use the rep |---|---|---| | `agent-team/` | Implicit-root Agent Teams roster, durable peer mailbox, shared task DAG, and runtime coordination | `ctx.agentTeams` | | `tool-agent-team/` | Scoped model-facing Agent Teams tools and collaboration guidance | — | +| `webworker-runtime/` | Browser-only host runtime: in-memory VFS, module loader, postMessage tunnel, and the dedicated Web Worker assembly | — | +| `webworker-packer/` | Build-time packer that materializes a profile's package closure into the VFS image the worker mounts | — | The [subtree rules](AGENTS.md) define dependency isolation, release exclusion, and promotion. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index a1751c39f5..27bb4d73b0 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -8,5 +8,7 @@ |---|---|---| | `agent-team/` | 隐式 root Agent Teams roster、持久 peer mailbox、共享任务 DAG 与运行时协调 | `ctx.agentTeams` | | `tool-agent-team/` | 按 Agent 作用域提供的 Agent Teams 模型工具与协作指引 | — | +| `webworker-runtime/` | 纯浏览器 host 运行时:内存 VFS、模块装载器、postMessage 隧道与 dedicated Web Worker 装配 | — | +| `webworker-packer/` | 构建期打包器:把 profile 的包闭包物化成 worker 挂载的 VFS 镜像 | — | [子树规则](AGENTS.md)规定依赖隔离、发布排除与 promotion。 diff --git a/packages/experimental/webworker-packer/README.i18n.yaml b/packages/experimental/webworker-packer/README.i18n.yaml new file mode 100644 index 0000000000..8ed28f6793 --- /dev/null +++ b/packages/experimental/webworker-packer/README.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 packages/experimental/webworker-packer/README.md +README.md: 15313f7b75169cc7a8749670900a0635605401e7 +README.zh.md: 2e2daba4c006e4d15db619e138447d5e239df4f6 diff --git a/packages/experimental/webworker-packer/README.md b/packages/experimental/webworker-packer/README.md new file mode 100644 index 0000000000..15313f7b75 --- /dev/null +++ b/packages/experimental/webworker-packer/README.md @@ -0,0 +1,27 @@ +# `@deepseek-ai/dsh-experimental-webworker-packer` + +English | [中文](README.zh.md) + +The VFS image packer: turns one composed profile into the single gzip-compressed tar the browser worker inflates and mounts as its filesystem ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). Nothing is compiled from source — the image carries the repository's real build products, so a preview deployment debugs exactly what the served deployment ships. + +The pack is a three-layer standard stack: + +1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain. +2. **Publish view** — each workspace package contributes the slice npm would publish (`files` through picomatch) minus the rule tables in `src/rules.ts` (no sources, no workspace `dist/`; external packages keep their trees minus the same exclude globs). +3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time. + +`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. + +## Model Experience + +None, as this package runs at build time and writes an image file; nothing it produces reaches a model request on its own. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **The rule tables are judgement calls** (`rules.ts`: exclude globs, page-asset patterns, entry seeds) pinned by `tests/`; a new asset class the worker must reach needs a table row, not a scanner change. +- **Vendored package sources (`src/*.ts`) no longer pack** — nothing resolves them at runtime; a future in-worker source-inspection feature would need a dedicated include rule. +- **The packer assumes built `lib/` artifacts are current**: it never compiles, so a stale workspace build packs stale bytes. Run the repository build first. diff --git a/packages/experimental/webworker-packer/README.zh.md b/packages/experimental/webworker-packer/README.zh.md new file mode 100644 index 0000000000..2e2daba4c0 --- /dev/null +++ b/packages/experimental/webworker-packer/README.zh.md @@ -0,0 +1,27 @@ +# `@deepseek-ai/dsh-experimental-webworker-packer` + +[English](README.md) | 中文 + +VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后当文件系统挂载的单个 gzip 压缩 tar([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。不做任何源码编译——镜像携带仓库真实构建产物,预览部署调试的正是 served 部署交付的字节。 + +打包是三层标准栈: + +1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。 +2. **发布视图**——每个 workspace 包贡献 npm 会发布的切片(`files` 走 picomatch),再减去 `src/rules.ts` 的规则表(无源码、无 workspace `dist/`;外部包保留整棵减同一套 exclude glob)。 +3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。 + +`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 + +## 模型体验 + +无:本包在构建期运行并写出镜像文件,其产物本身不进入任何模型请求。 + +#### KV Cache 影响 + +无:本包既不组装也不发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **规则表是判断题**(`rules.ts`:exclude glob、页面资产模式、入口种子),由 `tests/` 钉住;worker 需要触达的新资产类别应加表行,而不是改扫描器。 +- **vendored 包源码(`src/*.ts`)不再打包**——运行时无人解析它们;未来若有 worker 内源码巡检功能需要专门的 include 规则。 +- **打包器假定构建产物 `lib/` 是新鲜的**:它从不编译,工作区构建过期就打包过期字节。先跑仓库构建。 diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json new file mode 100644 index 0000000000..6e37359454 --- /dev/null +++ b/packages/experimental/webworker-packer/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-experimental-webworker-packer", + "description": "Build-time packer for the browser runtime's VFS image: materializes a profile's package closure into one gzip-compressed tar the worker mounts, with every module body pre-transformed", + "version": "0.1.0-rc.8", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/webworker-packer" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-pack-vfs-image": "./lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/bin.js", + "lib/repository-*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", + "js-yaml": "^4.2.0", + "picomatch": "^4.0.4" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/js-yaml": "^4.0.9", + "@types/picomatch": "^3.0.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + } +} diff --git a/packages/experimental/webworker-packer/src/bin.ts b/packages/experimental/webworker-packer/src/bin.ts new file mode 100644 index 0000000000..57a3c77730 --- /dev/null +++ b/packages/experimental/webworker-packer/src/bin.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Pack a VFS image from this repository: compose the profile, materialize the + * closure, lower every module body, write the gzip-compressed tar. + * + * Usage: dsh-pack-vfs-image --out [--profile web] [--root /dsh] + * node --import tsx/esm src/bin.ts --out ../../apps/web/dist/preview/vfs-image.tar.gz + * @module @deepseek-ai/dsh-experimental-webworker-packer/src/bin + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packVfsImage } from './pack.ts' +import { composeProfile, configTrees, describePack, indexWorkspacePackages } from './repository.ts' + +/** + * Read one `--flag value` pair. + * @param name - Flag name without dashes. + * @param fallback - Value when the flag is absent. + * @returns The value. + * @throws When the flag is present with no value, because silently packing the + * default profile is worse than stopping. + */ +function flag(name: string, fallback?: string): string { + const index = process.argv.indexOf(`--${name}`) + if (index === -1) { + if (fallback !== undefined) return fallback + throw new Error(`dsh-pack-vfs-image: --${name} is required`) + } + const value = process.argv[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`dsh-pack-vfs-image: --${name} needs a value`) + } + return value +} + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const profile = flag('profile', 'web') +const out = flag('out') +const outputFile = isAbsolute(out) ? out : resolve(process.cwd(), out) + +const result = packVfsImage({ + config: composeProfile(repoRoot, profile), + profile, + root: flag('root', '/dsh'), + workspaces: indexWorkspacePackages(repoRoot), + resolveFrom: repoRoot, + configTrees: configTrees(repoRoot), +}) + +if (result.missing.length > 0) { + throw new Error(`vfs image: ${String(result.missing.length)} dependencies did not resolve; the image would be incomplete`) +} + +mkdirSync(dirname(outputFile), { recursive: true }) +writeFileSync(outputFile, result.image) +process.stdout.write(describePack(result, repoRoot, outputFile).join('\n')) diff --git a/packages/experimental/webworker-packer/src/index.ts b/packages/experimental/webworker-packer/src/index.ts new file mode 100644 index 0000000000..ea054b26b0 --- /dev/null +++ b/packages/experimental/webworker-packer/src/index.ts @@ -0,0 +1,15 @@ +/** + * Build-time packer for the browser runtime's VFS image. + * @module @deepseek-ai/dsh-experimental-webworker-packer + */ +export { + WRAPPER_CONTRACT, + type ImageFiles, type TransformOutcome, +} from './transform-image.ts' +export { + CONFIG_PATH, DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, + type ConfigTree, type PackOptions, type PackResult, +} from './pack.ts' +export { + composeProfile, configTrees, describePack, indexWorkspacePackages, +} from './repository.ts' diff --git a/packages/experimental/webworker-packer/src/invariant.ts b/packages/experimental/webworker-packer/src/invariant.ts new file mode 100644 index 0000000000..bfa1060afb --- /dev/null +++ b/packages/experimental/webworker-packer/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-experimental-webworker-packer`. + * @module @deepseek-ai/dsh-experimental-webworker-packer/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-webworker-packer' + +/** Cordis companion plugin name. */ +export const name = 'webworker-packer-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package is a build-time pass with no + * production event stream or mutable data; the pack's own gates (unresolvable + * own requests, the all-or-nothing wrapper contract) fail the pack instead. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/experimental/webworker-packer/src/pack.ts b/packages/experimental/webworker-packer/src/pack.ts new file mode 100644 index 0000000000..2f71716e4d --- /dev/null +++ b/packages/experimental/webworker-packer/src/pack.ts @@ -0,0 +1,575 @@ +/** + * VFS image packer: turns one composed profile plus a package index into the single + * gzip-compressed tar the browser runtime inflates and mounts as its filesystem. + * + * Nothing is compiled here. The image carries the repository's real build products, + * so a preview deployment debugs exactly what the served deployment ships. What the + * pass does add is the pack-time module transform and the manifest that records the + * wrapper contract it was transformed against. + * + * This module holds no repository knowledge: paths, globs, and the composition come + * in as parameters, so the same library packs a different tree by being called + * differently. Locating those inputs is the CLI's job. + * @module @deepseek-ai/dsh-experimental-webworker-packer/src/pack + */ +import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { gzipSync } from 'node:zlib' + +import { + lowerModuleSource, MemoryVfs, packTar, WorkerModuleLoader, + DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_MANIFEST_PATH, +} from '@deepseek-ai/dsh-experimental-webworker-runtime' +import picomatch from 'picomatch' +import yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { REPLACED_EXTERNAL_PACKAGES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/external_packages/replaced-externals.ts' +import { MODULE_PROXIES, MODULE_PROXY_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-proxies.ts' +import { WRAPPER_CONTRACT, type ImageFiles, type TransformOutcome } from './transform-image.ts' +import { EXCLUDE, EXCLUDE_WORKSPACE, IMAGE_ENTRY_SEEDS, PAGE_ASSETS } from './rules.ts' + +export { DEFAULT_ROOT } from '@deepseek-ai/dsh-experimental-webworker-runtime' + +/** Image path of the manifest; the layout contract's name, re-exported for callers. */ +export const MANIFEST_PATH: string = IMAGE_MANIFEST_PATH + +/** Image path of the composed profile; the layout contract's name, re-exported for callers. */ +export const CONFIG_PATH: string = IMAGE_CONFIG_PATH + +/** + * Manifest field the runtime judges the image by: the wrapper contract every packed + * body was emitted against. The runtime refuses an image whose value is not its own + * contract, because those bodies assume different wrapper semantics. + */ +const CONTRACT_FIELD = 'lowered' + +/** Exclude matcher over tree-root-relative paths ({@link EXCLUDE}). */ +const excluded = picomatch([...EXCLUDE], { dot: true }) + +/** Workspace exclude matcher: {@link EXCLUDE} plus {@link EXCLUDE_WORKSPACE}. */ +const workspaceExcluded = picomatch([...EXCLUDE, ...EXCLUDE_WORKSPACE], { dot: true }) + +/** Page-asset matcher over image paths ({@link PAGE_ASSETS}). */ +const pageAsset = picomatch([...PAGE_ASSETS], { dot: true }) + +/** One directory tree to copy in verbatim beside the composition. */ +export interface ConfigTree { + /** Image path to mount it at, relative to the virtual root. */ + readonly mount: string + /** Absolute source directory. */ + readonly directory: string + /** + * Whether plugin names inside its `.yml` files join the materialization closure. + * An agent preset mounts plugins the base composition never lists, and creating a + * session fails if any of them is missing from the image. + */ + readonly scanRoster?: boolean +} + +/** Everything the packer needs that it cannot know by itself. */ +export interface PackOptions { + /** Composed profile, `!!js` intact, as the CLI's `--dump-default-config` produced it. */ + readonly config: string + /** Profile name, recorded in the manifest. */ + readonly profile: string + /** Virtual root the image mounts under; defaults to {@link DEFAULT_ROOT}. */ + readonly root?: string + /** Package name to absolute directory, for workspace and vendored packages. */ + readonly workspaces: ReadonlyMap + /** Directory Node-style dependency resolution walks up from for the roster. */ + readonly resolveFrom: string + /** Config trees to copy in beside the composition. */ + readonly configTrees?: readonly ConfigTree[] + /** Empty directories to create; defaults to `home/`, `workspace/`, `tmp/`. */ + readonly emptyDirectories?: readonly string[] + /** + * Extra sweep roots: image specifiers requested by code outside the image. + * Defaults to the worker assembly's own entries. + */ + readonly entries?: readonly string[] +} + +/** What one pack produced, for the caller to report or assert on. */ +export interface PackResult { + /** The gzip-compressed tar archive to write; the runtime inflates it at mount. */ + readonly image: Uint8Array + /** Every entry, before zipping; the manifest is already among them. */ + readonly files: ImageFiles + /** Package name to how many files it contributed, in materialization order. */ + readonly packages: ReadonlyMap + /** How many of them came from the workspace rather than from `node_modules`. */ + readonly workspacePackages: number + /** Roster package names the closure started from. */ + readonly roster: readonly string[] + /** Dependencies that did not resolve; a non-empty list means an incomplete image. */ + readonly missing: readonly string[] + /** Executable scripts dropped from the image. */ + readonly executables: readonly string[] + /** Page bundles left verbatim, and so out of the transform. */ + readonly pageBundles: readonly string[] + /** JavaScript entries the image carries. */ + readonly javascriptEntries: number + /** JavaScript candidates no root reaches, dropped from the image. */ + readonly droppedJavascriptEntries: number + /** Third-party requests that resolve nowhere; loud at require time if hit. */ + readonly unresolvedExternalRequests: readonly string[] + /** What the pack-time transform did. */ + readonly transform: TransformOutcome + /** Wrapper contract recorded in the manifest; every packed body meets it. */ + readonly contract: string +} + +const readJson = (file: string): Record => + JSON.parse(readFileSync(file, 'utf8')) as Record + +/** + * Package name of a module specifier. + * @param specifier - Module specifier, possibly with a subpath. + * @returns The package name (`@scope/pkg/sub` → `@scope/pkg`). + */ +function packageNameOf(specifier: string): string { + const [first = specifier, second = ''] = specifier.split('/') + return first.startsWith('@') ? `${first}/${second}` : first +} + +/** + * Collect module-specifier `name` fields from parsed entry rows, recursively + * through nested `config` row lists (groups). Builtin rows (`cordis:group`) + * and preset metadata documents carry names that are not module specifiers; + * only names with a scope or a path separator count. + * @param rows - Parsed YAML value; anything but an entry array is ignored. + * @param names - Package names collected so far. + */ +function moduleNamesOf(rows: unknown, names: Set): void { + if (!Array.isArray(rows)) return + for (const row of rows) { + if (typeof row !== 'object' || row === null) continue + const { name, config } = row as { name?: unknown; config?: unknown } + if (typeof name === 'string' && (name.startsWith('@') || name.includes('/'))) { + names.add(packageNameOf(name)) + } + moduleNamesOf(config, names) + } +} + +/** + * Package names the composition names. + * @param config - Composed profile; `!!js` scalars parse under Include's dialect. + * @returns Package names, deduplicated. + */ +function rosterOf(config: string): string[] { + const names = new Set() + moduleNamesOf(yaml.load(config, { schema: entryListSchema }), names) + return [...names] +} + +/** + * Package names the compositions under one config tree name. + * @param root - Directory to walk. + * @returns Package names, deduplicated. + */ +function treeRosterOf(root: string): string[] { + const names = new Set() + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolute = join(directory, entry.name) + if (entry.isDirectory()) { + walk(absolute) + continue + } + if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml')) continue + moduleNamesOf(yaml.load(readFileSync(absolute, 'utf8'), { schema: entryListSchema }), names) + } + } + walk(root) + return [...names] +} + +/** + * Resolve one dependency the way Node does: walk up from the importer. + * @param fromDirectory - Directory to start at. + * @param name - Package name. + * @returns The real path of the package directory, or undefined. + */ +function resolveDependency(fromDirectory: string, name: string): string | undefined { + let directory = fromDirectory + for (;;) { + const candidate = join(directory, 'node_modules', name) + if (existsSync(join(candidate, 'package.json'))) return realpathSync(candidate) + const parent = dirname(directory) + if (parent === directory) return undefined + directory = parent + } +} + +/** + * Collect files under one directory. Traversal mechanics live here — nested + * `node_modules` never mounts (the image is flat) and dot directories are + * tooling residue at any depth — while every judgement call comes in through + * `keep` (the {@link EXCLUDE} tables and the npm publish view). + * @param root - Source directory. + * @param into - Image entries to add to. + * @param prefix - Image path prefix. + * @param keep - Filter over root-relative paths. + */ +function collectTree(root: string, into: ImageFiles, prefix: string, keep: (relativePath: string) => boolean): void { + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name === 'node_modules') continue + if (entry.name.startsWith('.')) continue + walk(join(directory, entry.name)) + continue + } + if (!entry.isFile()) continue + const absolute = join(directory, entry.name) + const relativePath = relative(root, absolute).replaceAll('\\', '/') + if (!keep(relativePath)) continue + into[`${prefix}/${relativePath}`] = readFileSync(absolute) + } + } + walk(root) +} + +/** + * Predicate for npm's `files` allowlist, with standard glob semantics + * (picomatch). A pattern admits the path itself and everything under it, so a + * bare directory name publishes its whole tree; `!` patterns subtract from the + * admitted set; package.json is always published. + * @param patterns - The package.json `files` array. + * @returns Predicate over package-root-relative paths. + */ +function publishedFilter(patterns: readonly unknown[]): (path: string) => boolean { + const strings = patterns.filter((pattern): pattern is string => typeof pattern === 'string') + const normalize = (pattern: string): string => pattern.replace(/^\.\//, '').replace(/\/+$/, '') + const widen = (pattern: string): string[] => [pattern, `${pattern}/**`] + const positive = strings.filter(pattern => !pattern.startsWith('!')).map(normalize).flatMap(widen) + const negative = strings.filter(pattern => pattern.startsWith('!')).map(pattern => normalize(pattern.slice(1))).flatMap(widen) + const admits = picomatch(positive, { dot: true }) + const denies = negative.length > 0 ? picomatch(negative, { dot: true }) : (): boolean => false + return path => path === 'package.json' || (admits(path) && !denies(path)) +} + +/** What the reachability sweep kept, transformed, and dropped. */ +interface SweepOutcome { + readonly swept: ImageFiles + readonly transform: TransformOutcome + readonly javascriptEntries: number + readonly droppedJavascriptEntries: number + /** Third-party requests that resolve nowhere; loud at require time if hit. */ + readonly unresolvedExternalRequests: readonly string[] +} + +/** + * Keep only the JavaScript the worker can reach, transforming it on the way. + * + * Roots are the export faces of every materialized workspace and vendored + * package — the harness addresses them by constructed name at runtime (Loader + * rows, typert faces, delegating providers such as `-auto` pickers), so the + * sweep prunes files only inside third-party packages — plus the worker + * assembly's own image entries. Resolution runs the runtime loader's own + * algorithm over the candidate set, so pack-time reachability and boot-time + * resolution cannot drift, and a request that resolves nowhere — an undeclared + * or missing dependency — fails the pack rather than the boot. + * + * Two entry classes stay out of the walk by rule: page assets + * ({@link PAGE_ASSETS}) are evaluated by the page's module system, and + * non-JavaScript entries always stay because data reads go through fs paths + * this pass cannot see. + * @param files - Candidate entries after the publish-view filter. + * @param options - Pack options carrying the sweep roots. + * @param rootPackages - Roster package names from the workspace. + * @param root - Virtual root the candidates mount under. + * @returns The final entries plus the sweep's counts. + */ +function sweepImage( + files: ImageFiles, + options: PackOptions, + rootPackages: readonly string[], + root: string, +): SweepOutcome { + const decoder = new TextDecoder() + const encoder = new TextEncoder() + const vfs = new MemoryVfs() + for (const [name, bytes] of Object.entries(files)) { + if (name.endsWith('/')) vfs.seedDirectory(`${root}/${name}`) + else vfs.seed(`${root}/${name}`, bytes) + } + // The walk resolves static specifiers and never loads them, so one shared + // factory stands for every replaced module. + const stub = (): unknown => ({}) + const loader = new WorkerModuleLoader({ + vfs, + root, + staticModules: Object.fromEntries(Object.keys(MODULE_PROXIES).map(name => [name, stub])), + staticModulePrefixes: Object.fromEntries(Object.keys(MODULE_PROXY_PREFIXES).map(name => [name, stub])), + }) + + const queue: { specifier: string; from: string; importer: string; meta?: boolean }[] = (options.entries ?? IMAGE_ENTRY_SEEDS) + .map(specifier => ({ specifier, from: root, importer: 'worker assembly entry' })) + for (const name of rootPackages) { + const manifestBytes = files[`node_modules/${name}/package.json`] + if (manifestBytes === undefined) continue // materialize already reported it under `missing` + let manifest: { exports?: Record } + try { + manifest = JSON.parse(decoder.decode(manifestBytes)) as typeof manifest + } catch { + continue + } + // Every non-wildcard face is a root; a face resolving onto a page asset is + // kept verbatim below rather than excluded here. + const subpaths = manifest.exports === undefined + ? ['.'] + : Object.keys(manifest.exports).filter(key => key.startsWith('.') && !key.includes('*')) + for (const subpath of subpaths) { + queue.push({ specifier: subpath === '.' ? name : `${name}/${subpath.slice(2)}`, from: root, importer: `workspace face ${name}` }) + } + } + + const reached = new Map() + const seen = new Set() + const failures: string[] = [] + const tolerated = new Set() + let visited = 0 + let rewritten = 0 + for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) { + const { specifier, from, importer } = entry + let resolution + try { + resolution = loader.resolve(specifier, from) + } catch (reason) { + // Our own packages must declare what they request: an unresolvable + // request from a workspace or vendored file, a roster face, or the + // assembly entries is a pack defect. Third-party files keep the runtime + // philosophy instead — platform-dispatch branches the worker never + // evaluates may request node-only modules, and such a request fails loud + // at require time if it ever runs. + const external = importer.startsWith('node_modules/') && !importer.startsWith('node_modules/@deepseek-ai/') + // A meta-resolve request is a URL mapping, not a load: a missing target + // is tolerable from any importer — the call throws if it ever runs. + if (external || entry.meta === true) tolerated.add(`${importer}: "${specifier}"`) + else failures.push(`${importer}: "${specifier}" — ${(reason as Error).message}`) + continue + } + if (resolution.kind === 'static') continue + const path = resolution.path + if (seen.has(path)) continue + seen.add(path) + const key = path.slice(root.length + 1) + const bytes = files[key] + if (bytes === undefined) continue + if (!/\.[cm]?js$/.test(key) || pageAsset(key)) { + reached.set(key, bytes) + continue + } + visited += 1 + const { code, lowered, moduleRequests, metaResolveRequests } = lowerModuleSource({ filename: `/${key}`, source: decoder.decode(bytes) }) + if (lowered) rewritten += 1 + reached.set(key, lowered ? encoder.encode(code) : bytes) + const directory = path.slice(0, path.lastIndexOf('/')) + for (const request of moduleRequests) queue.push({ specifier: request, from: directory, importer: key }) + for (const request of metaResolveRequests) queue.push({ specifier: request, from: directory, importer: key, meta: true }) + } + if (failures.length > 0) { + throw new Error( + `vfs image: ${String(failures.length)} unresolvable module request(s); ` + + 'an undeclared or missing dependency fails the pack rather than the boot:\n ' + + failures.join('\n '), + ) + } + + const swept: ImageFiles = {} + let javascriptEntries = 0 + let dropped = 0 + for (const [name, bytes] of Object.entries(files)) { + const isJs = /\.[cm]?js$/.test(name) + if (!isJs || pageAsset(name)) { + swept[name] = bytes + if (isJs) javascriptEntries += 1 + continue + } + const kept = reached.get(name) + if (kept === undefined) { + dropped += 1 + continue + } + swept[name] = kept + javascriptEntries += 1 + } + return { + swept, + transform: { visited, rewritten }, + javascriptEntries, + droppedJavascriptEntries: dropped, + unresolvedExternalRequests: [...tolerated], + } +} + +/** + * Drop executable scripts from the image. + * + * A shebang says "program", not "module": nothing in a browser can spawn one and no + * consumer reads their bytes (the packages that expose a launcher path are replaced + * by stubs that answer with a string). They are also the one place top-level `await` + * appears in the closure, which a CommonJS body cannot express. + * @param files - Image entries, mutated. + * @returns The dropped entry names. + */ +function dropExecutables(files: ImageFiles): string[] { + const decoder = new TextDecoder() + const dropped: string[] = [] + for (const [name, bytes] of Object.entries(files)) { + if (!/\.[cm]?js$/.test(name)) continue + if (decoder.decode(bytes.subarray(0, 2)) !== '#!') continue + dropped.push(name) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- the image is a plain path map + delete files[name] + } + return dropped +} + +/** + * Materialize the dependency closure of every roster package into the image. + * @param roster - Package names to start from. + * @param options - Pack options carrying the workspace index and resolution root. + * @returns Image entries, per-package file counts, and unresolved dependencies. + */ +function materialize( + roster: readonly string[], + options: PackOptions, +): { files: ImageFiles; packages: Map; missing: string[] } { + const files: ImageFiles = {} + const packages = new Map() + const missing: string[] = [] + const replaced = new Set(REPLACED_EXTERNAL_PACKAGES) + const queue: { name: string; from: string }[] = roster.map(name => ({ name, from: options.resolveFrom })) + + for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) { + const { name, from } = entry + if (packages.has(name) || replaced.has(name)) continue + const directory = options.workspaces.get(name) ?? resolveDependency(from, name) + if (directory === undefined) { + missing.push(`${name} (from ${relative(options.resolveFrom, from) || '.'})`) + continue + } + const manifest = readJson(join(directory, 'package.json')) + const prefix = `node_modules/${name}` + const before = Object.keys(files).length + if (options.workspaces.has(name)) { + // A workspace package ships the slice npm would publish — `files` + // filters out build residue like the tsc mirror under lib/types/ — + // minus the workspace exclude table (no sources, no dist: the page + // serves its own assets). + const published = Array.isArray(manifest.files) ? publishedFilter(manifest.files) : undefined + collectTree(directory, files, prefix, relativePath => + !workspaceExcluded(relativePath) && (published === undefined || published(relativePath))) + } else { + collectTree(directory, files, prefix, relativePath => !excluded(relativePath)) + } + packages.set(name, Object.keys(files).length - before) + for (const field of ['dependencies', 'peerDependencies'] as const) { + // npm semantics: a peer is provided by the consumer. For an external + // package the consumer is the page (react behind the prebuilt client + // bundles), so its peer edges never bind the worker. Workspace and + // vendored packages declare real runtime seams as peers + // (@deepseek-ai/cordis is a peerDependency of every harness package), + // so their peer edges stay on the chain. + if (field === 'peerDependencies' && !options.workspaces.has(name)) continue + const dependencies = manifest[field] + if (typeof dependencies !== 'object' || dependencies === null) continue + for (const dependency of Object.keys(dependencies)) queue.push({ name: dependency, from: directory }) + } + } + return { files, packages, missing } +} + +/** Gzip header byte that records the packing platform; RFC 1952 §2.3.1 spells 255 "unknown". */ +const GZIP_OS_UNKNOWN = 255 + +/** Offset of that byte in the gzip member header. */ +const GZIP_OS_OFFSET = 9 + +/** + * Compress the archive into one gzip member the same tree always produces + * byte for byte. + * + * Two header fields would otherwise carry build facts: zlib writes no + * modification time and no original file name for a buffer (`gzipSync` is handed + * neither), and it fills the operating-system byte from the platform it was built + * for, which would make the same tree pack differently on Linux and macOS. That + * byte is overwritten with "unknown" — every gzip reader ignores it, and the + * artifact stops depending on where it was packed. + * @param archive - the ustar archive. + * @returns the compressed image bytes. + */ +function compressImage(archive: Uint8Array): Uint8Array { + const compressed = gzipSync(archive, { level: 9 }) + compressed[GZIP_OS_OFFSET] = GZIP_OS_UNKNOWN + return compressed +} + +/** + * Pack one VFS image. + * + * The manifest's claim is all-or-nothing: it names the one contract every packed body + * was emitted against. A module the transform cannot express therefore fails the pack + * rather than downgrading the image, because a mostly-transformed image boots into + * errors far from their cause. + * @param options - Composition, package index, and paths. + * @returns The compressed image plus what went into it. + * @throws When a config tree or workspace directory named in the options is missing, + * because a silently thinner image fails much later and much less clearly. + */ +export function packVfsImage(options: PackOptions): PackResult { + const root = options.root ?? DEFAULT_ROOT + const encoder = new TextEncoder() + const configTrees = options.configTrees ?? [] + for (const tree of configTrees) { + if (!existsSync(tree.directory)) { + throw new Error(`vfs image: config tree ${tree.mount} is missing at ${tree.directory}`) + } + } + + const roster = [...new Set([ + ...rosterOf(options.config), + ...configTrees.filter(tree => tree.scanRoster === true).flatMap(tree => treeRosterOf(tree.directory)), + ])] + const { files, packages, missing } = materialize(roster, options) + + files[CONFIG_PATH] = encoder.encode(options.config) + for (const tree of configTrees) collectTree(tree.directory, files, tree.mount, relativePath => !excluded(relativePath)) + + const executables = dropExecutables(files) + const rootPackages = [...packages.keys()].filter(name => options.workspaces.has(name)) + const { swept, transform, javascriptEntries, droppedJavascriptEntries, unresolvedExternalRequests } = + sweepImage(files, options, rootPackages, root) + + swept[MANIFEST_PATH] = encoder.encode(`${JSON.stringify({ + root, + profile: options.profile, + [CONTRACT_FIELD]: WRAPPER_CONTRACT, + javascriptEntries, + visitedEntries: transform.visited, + rewrittenEntries: transform.rewritten, + }, null, 2)}\n`) + + for (const directory of options.emptyDirectories ?? IMAGE_EMPTY_DIRECTORIES) { + swept[directory] = new Uint8Array(0) + } + + return { + image: compressImage(packTar(swept)), + files: swept, + packages, + workspacePackages: [...packages.keys()].filter(name => options.workspaces.has(name)).length, + roster, + missing, + executables, + pageBundles: Object.keys(swept).filter(name => pageAsset(name)), + javascriptEntries, + droppedJavascriptEntries, + unresolvedExternalRequests, + transform, + contract: WRAPPER_CONTRACT, + } +} diff --git a/packages/experimental/webworker-packer/src/repository.ts b/packages/experimental/webworker-packer/src/repository.ts new file mode 100644 index 0000000000..38ec64bd1d --- /dev/null +++ b/packages/experimental/webworker-packer/src/repository.ts @@ -0,0 +1,173 @@ +/** + * Repository knowledge for the packer: where this tree's workspaces, profile + * composition, and config trees are, and how to report a pack. + * + * The library half takes all of this as parameters. Keeping the lookup here is what + * lets the same library pack a different tree, and what keeps `pack.ts` free of + * assumptions about pnpm workspaces or the `dsh` CLI. + * @module @deepseek-ai/dsh-experimental-webworker-packer/src/repository + */ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths' +import type { ConfigTree, PackResult } from './pack.ts' + +/** + * Repository directories scanned for workspace and vendored packages. The + * image only ever materializes runtime packages, which all live here; + * examples, python, and native are never on a roster's dependency chain (the + * native addon is a replaced external). + */ +const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'apps'] + +/** Composition entry point package: the `dsh` CLI, run from source. */ +const CLI_PACKAGE = 'apps/cli' + +/** Composition entry point: the `dsh` CLI, run from source. */ +const CLI_ENTRY = `${CLI_PACKAGE}/src/bin.ts` + +/** + * Index every workspace and vendored package by name. + * @param repoRoot - Absolute repository root. + * @returns Package name to absolute directory. + */ +export function indexWorkspacePackages(repoRoot: string): Map { + const index = new Map() + const visit = (directory: string): void => { + const manifest = join(directory, 'package.json') + if (existsSync(manifest)) { + const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name + if (typeof name === 'string') index.set(name, directory) + // A package root owns its subtree; anything below (test fixtures, + // nested manifests) is not a separate workspace package. + return + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue + visit(join(directory, entry.name)) + } + } + for (const scanRoot of WORKSPACE_SCAN_ROOTS) { + const absolute = join(repoRoot, scanRoot) + if (existsSync(absolute)) visit(absolute) + } + return index +} + +/** + * Compose one profile through the real CLI dump path, leaving `!!js` + * unevaluated. The dump runs against a throwaway Harness home and default + * layers only, so the image is the shipped profile: the machine's `$DSH_HOME` + * — its profile manifest with locally installed bundles, and its patch files — + * would otherwise leak this machine's plugins into the image and break the + * same-tree-same-bytes guarantee. + * @param repoRoot - Absolute repository root. + * @param profile - Profile name to compose. + * @returns The composed YAML. + */ +export function composeProfile(repoRoot: string, profile: string): string { + const home = mkdtempSync(join(tmpdir(), 'dsh-pack-home-')) + try { + return execFileSync( + process.execPath, + ['--import', 'tsx/esm', join(repoRoot, CLI_ENTRY), '--profile', profile, '--dump-default-config'], + { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, [DSH_HOME_ENV]: home } }, + ) + } finally { + rmSync(home, { recursive: true, force: true }) + } +} + +/** One `dsh.configTrees` declaration entry, validated field by field. */ +interface ConfigTreeDeclaration { + mount: string + path: string + scanRoster?: boolean +} + +/** + * Config trees the CLI package declares for deployment images + * (`dsh.configTrees` in its package.json): `path` is relative to the CLI + * package root, `mount` is the image path, `scanRoster` feeds the tree's yml + * plugin rows into the pack roster. The CLI owns its config layout; this + * reader follows the declaration instead of naming directories. A malformed + * declaration refuses the pack. + * @param repoRoot - Absolute repository root. + * @returns Trees with absolute source directories. + */ +export function configTrees(repoRoot: string): ConfigTree[] { + const packageDir = join(repoRoot, CLI_PACKAGE) + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + dsh?: { configTrees?: unknown } + } + const declared = manifest.dsh?.configTrees + if (declared === undefined) return [] + if (!Array.isArray(declared)) { + throw new Error(`vfs image: ${CLI_PACKAGE} dsh.configTrees must be an array`) + } + const mounts = new Set() + return declared.map((entry, index) => { + const tree = entry as Partial | null + const at = `${CLI_PACKAGE} dsh.configTrees[${String(index)}]` + if (tree === null || typeof tree !== 'object' + || typeof tree.mount !== 'string' || tree.mount === '' + || typeof tree.path !== 'string' || tree.path === '' + || (tree.scanRoster !== undefined && typeof tree.scanRoster !== 'boolean')) { + throw new Error(`vfs image: ${at} must declare a string mount, a string path, and an optional boolean scanRoster`) + } + if (mounts.has(tree.mount)) { + throw new Error(`vfs image: ${at} repeats mount ${JSON.stringify(tree.mount)}`) + } + mounts.add(tree.mount) + return { + mount: tree.mount, + directory: join(packageDir, tree.path), + ...tree.scanRoster === undefined ? {} : { scanRoster: tree.scanRoster }, + } + }) +} + +/** + * Render one pack as the lines a build log should carry. + * + * Refusals and unresolved dependencies are the two states a reader must not miss, so + * they are spelled out rather than counted. + * @param result - What the pack produced. + * @param repoRoot - Absolute repository root, for relative paths. + * @param outputFile - Where the image was written. + * @returns Lines to print. + */ +export function describePack(result: PackResult, repoRoot: string, outputFile: string): string[] { + const sizeOf = (prefix: string): number => Object.entries(result.files) + .filter(([name]) => name.startsWith(prefix)) + .reduce((sum, [, bytes]) => sum + bytes.byteLength, 0) + const megabytes = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(2)} MB` + const workspaceCount = result.workspacePackages + const heaviest = [...result.packages.entries()] + .map(([name, count]) => ({ name, count, bytes: sizeOf(`node_modules/${name}/`) })) + .sort((left, right) => right.bytes - left.bytes) + .slice(0, 12) + + return [ + `vfs image: ${relative(repoRoot, outputFile)}`, + ` roster entries ${String(result.roster.length)}`, + ` packages ${String(result.packages.size)} (${String(workspaceCount)} workspace)`, + ` files ${String(Object.keys(result.files).length)}`, + ` raw ${megabytes(Object.values(result.files).reduce((sum, bytes) => sum + bytes.byteLength, 0))}`, + ` compressed ${megabytes(result.image.byteLength)}`, + ` config + presets ${megabytes(sizeOf('config/'))}`, + ` javascript entries ${String(result.javascriptEntries)} (dropped ${String(result.executables.length)} executable scripts, ${String(result.pageBundles.length)} page bundles verbatim)`, + ` wrapper contract ${result.contract}`, + ` transform ${String(result.transform.rewritten)} of ${String(result.transform.visited)} reached entries rewritten, ${String(result.droppedJavascriptEntries)} unreachable dropped`, + ` unresolved ${String(result.unresolvedExternalRequests.length)} third-party request(s) left to fail loud at require time`, + ' heaviest packages:', + ...heaviest.map(entry => ` ${entry.bytes.toString().padStart(9)} B ${entry.name} (${String(entry.count)} files)`), + ...result.missing.length === 0 + ? [] + : [' unresolved dependencies:', ...result.missing.map(entry => ` ${entry}`)], + '', + ] +} diff --git a/packages/experimental/webworker-packer/src/rules.ts b/packages/experimental/webworker-packer/src/rules.ts new file mode 100644 index 0000000000..96c1fa0265 --- /dev/null +++ b/packages/experimental/webworker-packer/src/rules.ts @@ -0,0 +1,70 @@ +/** + * Pack rule tables: the one place the image's include/exclude decisions live. + * Patterns are picomatch globs. Exclude patterns match tree-root-relative + * paths (so `src/**` drops only a root-level source tree), page-asset + * patterns match image paths. Traversal mechanics — nested `node_modules` + * flattening and dot-directory pruning — stay in the collector; these tables + * hold the judgement calls. + */ + +/** + * Paths dropped from every collected tree. Source and test trees never + * resolve at runtime (the artifact plane ships `lib/`), and sourcemaps, + * declarations, and archives never resolve either while dominating the byte + * count. + */ +export const EXCLUDE: readonly string[] = [ + 'src/**', + 'tests/**', + 'test/**', + '__tests__/**', + 'coverage/**', + '**/*.map', + '**/*.tsbuildinfo', + '**/*.tgz', + '**/*.tar', + '**/*.tar.gz', + '**/*.d.ts', + '**/*.d.mts', + '**/*.d.cts', +] + +/** + * Additional paths dropped from workspace packages only. A workspace `dist/` + * is a page-asset tree the static deployment serves itself; external packages + * legitimately ship runtime code under `dist/`. + */ +export const EXCLUDE_WORKSPACE: readonly string[] = [ + 'dist/**', +] + +/** + * Image paths that belong to the PAGE, not to the worker's loader. + * + * A package's `lib/client.js` is its browser bundle behind the `./client` + * export: the page's own module system evaluates it with its own wrapper, + * which has no ambient-store parameter. Transforming those bodies would + * inject calls the page cannot resolve, so they ship verbatim — and the + * manifest's all-or-nothing claim stays true, because the worker loader never + * evaluates them (the tunnel serves them as bytes). + */ +export const PAGE_ASSETS: readonly string[] = [ + 'node_modules/*/lib/client.js', + 'node_modules/@*/*/lib/client.js', +] + +/** + * Image specifiers the worker assembly requires directly, beyond the composed + * roster: they are requested by worker-bundle code, so no image file + * references them and the reachability sweep must seed them as roots. Keep in + * step with the literal `require`/`resolve` calls in the runtime's + * `worker-host.ts`. + */ +export const IMAGE_ENTRY_SEEDS: readonly string[] = [ + '@deepseek-ai/dsh-app-boot', + '@deepseek-ai/dsh-cmdline', + '@deepseek-ai/dsh-host-apiproxy', + '@deepseek-ai/cordis', + '@deepseek-ai/cordis-plugin-include', + 'js-yaml', +] diff --git a/packages/experimental/webworker-packer/src/transform-image.ts b/packages/experimental/webworker-packer/src/transform-image.ts new file mode 100644 index 0000000000..18b525c06e --- /dev/null +++ b/packages/experimental/webworker-packer/src/transform-image.ts @@ -0,0 +1,26 @@ +/** + * The wrapper contract packed bodies are emitted against, and the image-entry + * types the pack pass consumes. + * + * One transform serves both sides — the pack pass lowers with the runtime's + * own `lowerModuleSource`, never a reimplementation — and the image records + * the contract version it was lowered against. Bodies emitted against a + * different wrapper contract are refused at mount time rather than + * half-working at run time. + * @module @deepseek-ai/dsh-experimental-webworker-packer/src/transform-image + */ +import { LOWERING_VERSION } from '@deepseek-ai/dsh-experimental-webworker-runtime' + +/** Image entries, keyed by their path relative to the virtual root. */ +export type ImageFiles = Record + +/** Wrapper contract the packed bodies are emitted against. */ +export const WRAPPER_CONTRACT: string = LOWERING_VERSION + +/** What one pack-time transform pass did. */ +export interface TransformOutcome { + /** JavaScript entries visited. */ + readonly visited: number + /** How many changed; the rest were already in final form. */ + readonly rewritten: number +} diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts new file mode 100644 index 0000000000..07ccb0986f --- /dev/null +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -0,0 +1,138 @@ +/** + * End-to-end spec of the packer's actual product: an image this package builds must + * mount in the runtime's VFS and be `require`-able by the runtime's module loader, + * which holds no transform of its own. + * + * That last part is the point. "It boots" only proves nothing crashed; the loader + * wraps module bodies exactly as the image holds them, so the pack-time pass is the + * only thing that can make them wrappable. The refusal case is the positive + * evidence: restore one un-lowered body and the same setup fails loud. + * + * A small synthetic composition rather than the real profile: packing the full + * closure takes tens of seconds. The path under test — compose, materialize, + * transform, tar, compress, inflate, mount, require — is the same one. + * + * ONE module instance: every runtime import here goes through `src/`, because the VFS + * and the active loader are module-level slots. The "starts with nothing loaded" + * case asserts the instance the spec holds is the one that did the work. + */ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts' +import { WorkerModuleLoader } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' +import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts' +import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' +import { indexWorkspacePackages } from '../src/repository.ts' +import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage } from '../src/pack.ts' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) + +/** A leaf workspace package: real build output, no dependencies to drag in. */ +const SUBJECT = '@deepseek-ai/dsh-timeout' + +const workspaces = indexWorkspacePackages(repoRoot) + +/** + * The pack consumes built `lib/` output. An unbuilt checkout (the unit + * coverage lane runs before any build) self-skips; the built lanes and every + * preview build exercise this same path against real artifacts. + */ +const subjectBuilt = existsSync(join(repoRoot, 'packages/util/timeout/lib/index.js')) + +let memo: ReturnType | undefined +const packed = (): ReturnType => memo ??= packVfsImage({ + // The composition's own shape: one entry per plugin, `name:` on its own line. + config: `- id: subject\n name: '${SUBJECT}'\n`, + profile: 'image-loadable-check', + workspaces, + resolveFrom: repoRoot, + // Synthetic composition: nothing boots the worker assembly, so its default + // image entries must not be demanded of this one-package closure. + entries: [], +}) + +/** The image's archive, inflated once: mounting reads the tar, not the gzip member. */ +let archiveMemo: Uint8Array | undefined +const archive = async (): Promise => + archiveMemo ??= await inflateImage(packed().image, 'the image this spec packed') + +;(subjectBuilt ? describe : describe.skip)('packed image', () => { + it('materializes the roster with every dependency resolved', () => { + const result = packed() + expect(workspaces.has(SUBJECT)).toBe(true) + expect(result.roster).toEqual([SUBJECT]) + expect(result.packages.has(SUBJECT)).toBe(true) + expect(result.missing).toEqual([]) + }) + + it('records the wrapper contract in the manifest and rewrote what it visited', () => { + const result = packed() + expect(Object.hasOwn(result.files, MANIFEST_PATH)).toBe(true) + const manifest = JSON.parse(new TextDecoder().decode(result.files[MANIFEST_PATH])) as { lowered: string } + expect(manifest.lowered).toBe(result.contract) + expect(result.transform.rewritten).toBeGreaterThan(0) + }) + + it('writes one gzip member whose header records no build facts', () => { + const image = packed().image + // RFC 1952 §2.3: magic, deflate, then the flag byte — no FNAME (0x08) or + // FCOMMENT, a zero modification time, and "unknown" for the packing system. + expect([...image.slice(0, 4)]).toEqual([0x1f, 0x8b, 0x08, 0x00]) + expect([...image.slice(4, 8)]).toEqual([0, 0, 0, 0]) + expect(image[9]).toBe(255) + }) + + it('packs the same tree to the same bytes', () => { + // The preview build compares a freshly packed image against the shipped one, + // so anything the compressor takes from its environment would read as a + // changed tree. + const again = packVfsImage({ + config: `- id: subject\n name: '${SUBJECT}'\n`, + profile: 'image-loadable-check', + workspaces, + resolveFrom: repoRoot, + entries: [], + }) + expect(Buffer.from(again.image).equals(Buffer.from(packed().image))).toBe(true) + }) + + it('mounts and requires through the real loader, which carries no transform', async () => { + const vfs = loadVfsImage(await archive(), DEFAULT_ROOT) + expect(vfs.existsSync(`${DEFAULT_ROOT}/node_modules/${SUBJECT}/lib/index.js`)).toBe(true) + + const loader = new WorkerModuleLoader({ + vfs, + root: DEFAULT_ROOT, + staticModules: createNodeBuiltins(), + staticModulePrefixes: REPLACED_PREFIXES, + }) + // The loader this spec reads counters from must be the one that did the + // requiring; a second instance would report an empty cache trivially. + expect(loader.usage().modules).toBe(0) + + const required = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(SUBJECT) as Record + expect(typeof required.timeoutOf).toBe('function') + expect(loader.usage().modules).toBeGreaterThan(0) + }) + + it('refuses a body the packer did not lower, naming the image', async () => { + // The case above only proves the packed bytes are wrappable. This is the + // other half: the loader has no transform to fall back on, so an entry the + // collector missed must fail loud against the image rather than boot. + const vfs = loadVfsImage(await archive(), DEFAULT_ROOT) + vfs.seed( + `${DEFAULT_ROOT}/node_modules/${SUBJECT}/lib/index.js`, + new TextEncoder().encode('export const timeoutOf = () => 0\n'), + ) + const loader = new WorkerModuleLoader({ + vfs, + root: DEFAULT_ROOT, + staticModules: createNodeBuiltins(), + staticModulePrefixes: REPLACED_PREFIXES, + }) + expect(() => loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(SUBJECT)) + .toThrow(/still carries module syntax, so the image was not lowered by the packer/) + }) +}) diff --git a/packages/experimental/webworker-packer/tsconfig.json b/packages/experimental/webworker-packer/tsconfig.json new file mode 100644 index 0000000000..7039bfa53b --- /dev/null +++ b/packages/experimental/webworker-packer/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../webworker-runtime" + }, + { + "path": "../../util/home-paths" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/experimental/webworker-packer/tsdown.config.ts b/packages/experimental/webworker-packer/tsdown.config.ts new file mode 100644 index 0000000000..b948ddb571 --- /dev/null +++ b/packages/experimental/webworker-packer/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * The packer ships TWO entries: the library (`index`) and the `dsh-pack-vfs-image` + * CLI (`bin`), the latter referenced by package.json `bin`. The root tsdown + * builds only `lib/types/index.js`, so this override adds `lib/types/bin.js`. + * Declarations come from `tsc -b` (dts: false), matching every package. + */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/bin.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml new file mode 100644 index 0000000000..0dced963dc --- /dev/null +++ b/packages/experimental/webworker-runtime/README.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 packages/experimental/webworker-runtime/README.md +README.md: b82c65b981be6a9405ae72e3a24a42c68b52696e +README.zh.md: 97160641a38095026d5103f2e423846bfb41d5a6 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md new file mode 100644 index 0000000000..b82c65b981 --- /dev/null +++ b/packages/experimental/webworker-runtime/README.md @@ -0,0 +1,33 @@ +# `@deepseek-ai/dsh-experimental-webworker-runtime` + +English | [中文](README.zh.md) + +The browser worker host: the whole harness plugin tree runs inside one dedicated Web Worker, for preview deployments and packaging regressions ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). The worker inflates a packed VFS image off its download and mounts it in memory, loads its modules through a CommonJS wrapper loader, and serves the page over a postMessage tunnel that speaks plain HTTP. + +Three artifacts from one tsdown pipeline: + +- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the image (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and replaced externals. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). The grammar is `@yarnpkg/parsers`' `parseShell`; this package owns the evaluator (pipelines, `&&`/`||`, subshells, redirections, expansion, globs) and the command table, which is the only `/bin` that exists — a name it does not hold reports `command not found`, and `execSync`/`fork` still refuse, because they need a real process. +- **`lib/client.js` (page half)** — `connectWorkerHost(worker, { image? })` completes the pre-Cordis handshake: the opening `init` frame carries the image URL (the one deployment-shaped input), the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. + +Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the worker boot in headless Chromium. + +## Model Experience + +None, as this package only hosts the tree in a browser worker and answers its `node:*` calls; every model-facing registration belongs to the plugins it boots. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`. +- **The skill catalog is never cached in the worker** — `skill-filesystem` watches its roots through `node:fs.watchFile`, which this package refuses, so every discovery pass returns an incomplete observation and re-scans. Discovery itself stays correct; the cost is a re-scan on every pass. +- **`node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing a real process or realm isolation cannot run here. +- **The bash tool runs only under `danger-full-access`**: a browser has no kernel to confine a command with, so `ctx.sandbox.confine` fails loud in every other permission preset and the command never starts. The mode is the deployment's own user-facing switch, not a worker-specific composition. +- **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there. +- **The shell is not bash**: no loops, functions, `case`, job control, or process substitution — the grammar stops at pipelines, `&&`/`||`, subshells, groups, redirections, and expansion. `&` runs its command to completion in place, `sed` accepts only substitution scripts, patterns are JavaScript regular expressions, and the command table holds coreutils only (no `git`, no network tools). +- **A shell process has no synchronous filesystem**: it reads and writes the host's VFS by message, because blocking on a reply would need `SharedArrayBuffer`, which requires a cross-origin isolation GitHub Pages cannot grant. Directory-walking commands therefore cost one round trip per entry, and two concurrent commands can interleave their writes. +- **Transport, worker-host, and page-half coverage needs a browser-grade harness** — the per-file coverage gate is unmet for those modules; unit specs cover storage, ALS, the transform, and the stub contracts. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md new file mode 100644 index 0000000000..97160641a3 --- /dev/null +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -0,0 +1,33 @@ +# `@deepseek-ai/dsh-experimental-webworker-runtime` + +[English](README.md) | 中文 + +浏览器 worker 宿主:整棵 harness 插件树跑在一个 dedicated Web Worker 里,用于预览部署与打包回归([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。worker 边下载边解压打包好的 VFS 镜像并挂载进内存,经 CommonJS 包装加载器装载模块,并通过一条讲纯 HTTP 的 postMessage 隧道服务页面。 + +一条 tsdown 管线出三个产物: + +- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载镜像(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS/隧道/浏览器原语,浏览器做不到的走结构化 stub(调用即 console 报错并抛出),外部包整体替换。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。语法来自 `@yarnpkg/parsers` 的 `parseShell`;求值器(管道、`&&`/`||`、子 shell、重定向、展开、glob)与命令表由本包自持,而命令表就是这里唯一存在的 `/bin`——表里没有的名字报 `command not found`,`execSync`/`fork` 依然拒绝,因为它们需要真进程。 +- **`lib/client.js`(页面半)**——`connectWorkerHost(worker, { image? })` 完成 pre-Cordis 握手:开局 `init` 帧携带镜像 URL(唯一部署形态输入),boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 + +验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 worker 启动。 + +## 模型体验 + +无:本包只在浏览器 worker 里承载插件树并应答它的 `node:*` 调用;所有面向模型的注册都属于它启动的那些插件。 + +#### KV Cache 影响 + +无:本包既不组装也不发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`。 +- **worker 里的技能目录从不缓存**——`skill-filesystem` 用 `node:fs.watchFile` 监听各个根,而本包拒绝该调用,于是每轮发现都返回不完整观测并重新扫描。发现本身仍然正确,代价是每轮都要重扫。 +- **`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要真进程或真 realm 隔离的行在此无法运行。 +- **bash 工具只在 `danger-full-access` 下可用**:浏览器没有内核可以约束命令,因此在其余权限档位下 `ctx.sandbox.confine` 会响亮失败、命令根本不会启动。该档位是部署本身的用户面开关,不是 worker 特有的组合差异。 +- **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。 +- **这个 shell 不是 bash**:没有循环、函数、`case`、作业控制或进程替换——语法止步于管道、`&&`/`||`、子 shell、group、重定向与展开。`&` 会就地把命令跑完,`sed` 只接受替换脚本,模式是 JavaScript 正则,命令表只有 coreutils(没有 `git`,没有网络工具)。 +- **shell 进程没有同步文件面**:它靠消息读写宿主的 VFS,因为阻塞等待回帧需要 `SharedArrayBuffer`,而那要求 GitHub Pages 给不了的跨源隔离。因此目录遍历类命令每个条目一次往返,并发的两条命令写入可以交错。 +- **transport、worker-host、页面半的覆盖需要浏览器级 harness**——这些模块未达 per-file 覆盖门;单测覆盖 storage、ALS、transform 与 stub 契约。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json new file mode 100644 index 0000000000..8cac2bd9f7 --- /dev/null +++ b/packages/experimental/webworker-runtime/package.json @@ -0,0 +1,64 @@ +{ + "name": "@deepseek-ai/dsh-experimental-webworker-runtime", + "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", + "version": "0.1.0-rc.8", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/webworker-runtime" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./worker": "./lib/worker.js", + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + } + }, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.3.0", + "@yarnpkg/parsers": "^3.1.0", + "acorn": "^8.17.0", + "buffer": "^6.0.3", + "picomatch": "^4.0.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@types/picomatch": "^3.0.2" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/worker.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/experimental/webworker-runtime/src/client/api-client.ts b/packages/experimental/webworker-runtime/src/client/api-client.ts new file mode 100644 index 0000000000..0e99075d47 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/client/api-client.ts @@ -0,0 +1,33 @@ +/** + * Page-side API carrier over the postMessage tunnel. Only `doFetch` is + * implemented: the streaming methods stay on `AbstractApiClient`'s default + * `readSse`, which is exactly what the worker answers on the two event-stream + * paths — so unary calls and downstream streams share one framing and neither + * side needs a WebSocket. + */ +import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' +import type { WorkerTunnel } from './client.ts' + +/** API client whose requests travel the worker tunnel instead of the network. */ +export class WorkerApiClient extends AbstractApiClient { + private readonly tunnel: WorkerTunnel + + /** + * Bind the carrier to a tunnel. + * @param tunnel - page half of the worker tunnel. + */ + constructor(tunnel: WorkerTunnel) { + super() + this.tunnel = tunnel + } + + /** + * Send one request through the tunnel. + * @param input - request URL. + * @param init - fetch init; the tunnel honours method, headers, body, and signal. + * @returns the reconstructed response. + */ + protected doFetch(input: URL, init?: RequestInit): Promise { + return this.tunnel.fetch(input, init) + } +} diff --git a/packages/experimental/webworker-runtime/src/client/apply-injections.ts b/packages/experimental/webworker-runtime/src/client/apply-injections.ts new file mode 100644 index 0000000000..163729a6aa --- /dev/null +++ b/packages/experimental/webworker-runtime/src/client/apply-injections.ts @@ -0,0 +1,50 @@ +/** + * Page-side interpreter for the structured index injection table. The served + * form renders the same rows into index.html text; a static worker page has + * no served HTML, so it executes the table directly. Rows execute strictly in + * table order, so a global row lands before the scripts that read it. + */ +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' + +function assertNever(row: never): never { + throw new Error(`webworker-runtime: unknown index injection row ${JSON.stringify(row)}`) +} + +/** + * Execute every row in table order. + * @param rows - Injection table from the boot payload. + * @param loadScript - Executes one script-src row; the tunnel's `loadBundle`, + * because the row URLs (`/plugins/...`) resolve only through the worker. + */ +export async function applyIndexInjections( + rows: readonly IndexInjection[], + loadScript: (src: string) => Promise, +): Promise { + for (const row of rows) { + switch (row.kind) { + case 'global': + (globalThis as Record)[row.name] = row.value + break + case 'script': { + const el = document.createElement('script') + el.textContent = row.text + ;(row.placement === 'head' ? document.head : document.body).append(el) + break + } + case 'script-src': + await loadScript(row.src) + break + case 'style': { + const el = document.createElement('style') + el.textContent = row.text + document.head.append(el) + break + } + case 'html': + (row.placement === 'head' ? document.head : document.body).insertAdjacentHTML('beforeend', row.html) + break + default: + assertNever(row) + } + } +} diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts new file mode 100644 index 0000000000..cd8dbcb7dc --- /dev/null +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -0,0 +1,342 @@ +/** + * Page half of the postMessage tunnel. It + * turns fetch-shaped calls into `req` frames and rebuilds Responses from the + * worker's `res` / `res-head`+`res-chunk`+`res-end` frames, so every consumer + * (boot payload, bundle transport, ApiClient, Typert RPC) speaks plain HTTP. + */ + +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' + +/** Frame sent to the worker. */ +interface RequestFrame { + t: 'req' + id: number + method: string + /** Absolute URL; the worker derives `req.url` (pathname + search) from it. */ + url: string + headers: Record + body?: ArrayBuffer | undefined +} + +/** Cancellation of an in-flight request or stream. */ +interface AbortFrame { + t: 'abort' + id: number +} + +/** Frames received from the worker. */ +type ResponseFrame = + | { t: 'res'; id: number; status: number; headers: Record; body?: ArrayBuffer; message?: string } + | { t: 'res-head'; id: number; status: number; headers: Record } + | { t: 'res-chunk'; id: number; chunk: ArrayBuffer } + | { t: 'res-end'; id: number } + | { t: 'res-err'; id: number; message: string } + +/** Boot payload of the tunnel bootstrap route. */ +export interface BootPayload { + /** Structured index injection table, executed by the page interpreter. */ + injections: IndexInjection[] +} + +/** Fetch-shaped transport the client tree consumes. */ +export type TunnelFetch = (input: URL | string, init?: RequestInit) => Promise + +interface PendingUnary { + resolve(response: Response): void + reject(reason: Error): void +} + +/** + * Statuses the worker only produces when the host refused the exchange rather than + * answered it; a route's own 4xx is the tree talking and stays silent here. + */ +const REFUSAL_STATUS = 500 + +const encoder = new TextEncoder() + +/** Normalize a RequestInit body to a transferable ArrayBuffer. */ +function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined { + if (body === undefined || body === null) return undefined + if (typeof body === 'string') return encoder.encode(body).buffer + if (body instanceof ArrayBuffer) return body + if (ArrayBuffer.isView(body)) { + return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) + } + throw new Error(`web-preview tunnel: unsupported request body ${Object.prototype.toString.call(body)}`) +} + +/** Statuses whose Response must carry a null body. */ +const NULL_BODY_STATUS = new Set([101, 204, 205, 304]) + +/** The page half of the tunnel: one `fetch`-shaped face over `postMessage`. */ +export class WorkerTunnel { + private readonly worker: Worker + private nextId = 1 + private readonly unary = new Map() + private readonly streams = new Map>() + /** + * In-flight request descriptions, so a refusal names what was refused. + * + * A tunnel failure and a failure inside the host tree look identical from the + * page — both surface as one rejected fetch — and the acceptance run keeps the + * page console but not the frames. Warning here separates the two without + * recording anything on the normal path, where no refusal frame ever arrives. + */ + private readonly inFlight = new Map() + + /** Body-phase abort listeners, released when their stream settles. */ + private readonly releases = new Map void>() + + /** + * Attach to a spawned worker and start consuming response frames. + * @param worker - the host worker. + */ + constructor(worker: Worker) { + this.worker = worker + worker.addEventListener('message', (event: MessageEvent) => { + this.receive(event.data) + }) + worker.addEventListener('error', (event) => { + const reason = new Error(`web-preview tunnel: worker failed: ${event.message}`) + for (const id of this.inFlight.keys()) this.warnRefusal(id, `worker failed: ${event.message}`) + this.inFlight.clear() + for (const pending of this.unary.values()) pending.reject(reason) + this.unary.clear() + for (const controller of this.streams.values()) controller.error(reason) + this.streams.clear() + for (const release of this.releases.values()) release() + this.releases.clear() + }) + } + + /** + * Open the tunnel: the worker assembles its host from this frame. + * @param image - VFS image URL the worker fetches. + */ + init(image: string): void { + this.worker.postMessage({ t: 'init', image }) + } + + /** Fetch-shaped entry: one request frame, one Response (streamed when the worker streams). */ + readonly fetch: TunnelFetch = async (input, init) => { + const signal = init?.signal + // Checked before any frame leaves: a request the caller already abandoned + // must not reach the worker, where a write-shaped route would still run. + if (signal?.aborted === true) throw new DOMException('The operation was aborted.', 'AbortError') + const id = this.nextId++ + const frame: RequestFrame = { + t: 'req', + id, + method: init?.method ?? 'GET', + url: new URL(input, globalThis.location.origin).toString(), + headers: Object.fromEntries(new Headers(init?.headers).entries()), + ...(init?.body === undefined || init.body === null + ? {} + : { body: toBodyBuffer(init.body) }), + } + const response = new Promise((resolve, reject) => { + this.unary.set(id, { resolve, reject }) + }) + this.inFlight.set(id, `${frame.method} ${frame.url}`) + this.worker.postMessage(frame) + if (signal === undefined || signal === null) return await response + const raced = this.rejectOnAbort(id, signal) + try { + const settled = await Promise.race([response, raced.rejected]) + // A streaming response outlives its head: hand the signal to the body + // phase, so a later stop still ends the stream and reaches the worker. + if (this.streams.has(id)) this.observeStreamAbort(id, signal) + return settled + } finally { + raced.release() + } + } + + /** + * Read the pre-cordis boot payload (the injection table). + * @returns The payload the page applies before the client tree loads. + */ + async bootPayload(): Promise { + const response = await this.fetch('/__boot__') + if (!response.ok) { + throw new Error(`web-preview tunnel: boot payload failed with HTTP ${String(response.status)}: ${await response.text()}`) + } + return await response.json() as BootPayload + } + + /** + * `loadBundle` seam: take one client bundle through the tunnel and execute it + * as a classic script, exactly like the shell's same-origin `' + /** * Render rows into an index.html body: head rows immediately after the * opening head tag, body rows immediately after the opening body tag, each - * group in table order. + * group in table order, and the boot-readiness tail after the last body row. * @param html - the raw index.html body. * @param rows - the collected injection table. * @returns the html with every row rendered. @@ -87,6 +97,7 @@ export function renderIndexInjections(html: string, rows: readonly IndexInjectio if (rendered.placement === 'head') head += rendered.markup else body += rendered.markup } + body += READY_MARKUP let out = html if (head !== '') { const open = /]*)?>/i.exec(out) diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index ffe5b4648d..e8fa315ecc 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -241,11 +241,13 @@ describe('real Loader composition', () => { expect(server.renderIndex('')).toContain('window.__Q__=2') untap() - // Tag-less fragments: head rows prepend, body rows append. + // Tag-less fragments: head rows prepend, body rows append, and the + // boot-readiness tail lands after the last body row. expect(renderIndexInjections('
x
', [ { kind: 'script', placement: 'head', text: 'H' }, { kind: 'script', placement: 'body', text: 'B' }, - ])).toBe('
x
') + ])).toBe('
x
' + + '') }) it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => { From 50bfb00985db4e756f99ba9f89200326db633507 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:20:27 +0800 Subject: [PATCH 067/248] feat(web): single-build preview page and its acceptance e2e One Vite build emits dist/index.html and dist/preview.html sharing every chunk; the only difference is one prepended bootstrap entry whose module connects the worker host, so the page from the stock entry onward is the served startup chain verbatim. The dist moves to a relative base so the preview mounts under any static directory, and the served form anchors deep SPA-fallback paths with a rendered . The preview-boot e2e serves the real built pages, packs the VFS image when absent, and holds the boot line's lowering contract, the interactive hero, and a clean page-error channel in headless Chromium. --- ...worker-pack-lowering-and-preview.i18n.yaml | 6 + ...-20-webworker-pack-lowering-and-preview.md | 34 +++ ...-webworker-pack-lowering-and-preview.zh.md | 34 +++ apps/web/package.json | 15 +- apps/web/src/preview.ts | 12 + apps/web/src/vite-env.d.ts | 1 + apps/web/tests/preview-boot.e2e.ts | 242 ++++++++++++++++++ apps/web/tests/pwa-manifest.e2e.ts | 2 +- apps/web/tsconfig.json | 1 + apps/web/vite.config.ts | 55 +++- packages/host/frontend-static/src/index.ts | 14 +- .../tests/frontend-static.spec.ts | 4 +- scripts/check-workspace-constraints.ts | 7 +- tsconfig.host.json | 1 + 14 files changed, 416 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md create mode 100644 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md create mode 100644 apps/web/src/preview.ts create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tests/preview-boot.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml new file mode 100644 index 0000000000..b2687ac5fd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.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 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md +2026-08-20-webworker-pack-lowering-and-preview.md: d4a3d0b2125421e761eb1616a7605b58d0d77da3 +2026-08-20-webworker-pack-lowering-and-preview.zh.md: 24ff21957c31783d1b375c6589bc6114b3be1972 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md new file mode 100644 index 0000000000..d4a3d0b212 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md @@ -0,0 +1,34 @@ +# Agent Note: pack-time lowering and the single-build preview + +Status: implemented + +English | [中文](2026-08-20-webworker-pack-lowering-and-preview.zh.md) + +## Problem + +The browser worker can neither compile modules at load nor be served by the product webserver: every module body must arrive runnable, and the page must be a static artifact. Both surfaces drifted early. The loader carried a fallback compiler, so a collector gap surfaced as a slow boot instead of a broken image — and `acorn` rode into `lib/worker.js` through the package barrel, a parser a runtime that only wraps pre-lowered bodies never needs. The preview was a second HTML template beside the served one, a page the served index could silently drift away from. + +## Decision + +**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. + +**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `` at serve time, keeping the on-disk pages byte-shared. + +Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental-*`, private and outside official releases. The boundary that carries product promises stays in the product packages: the injection table, `__DSH_TRANSPORT__`, and the `/plugins` bundle bytes are owned by `dsh-host-webserver`, `dsh-client-modules`, and `dsh-client-connection`. + +## Alternatives considered + +**A load-time transform as a safety net.** It turned a broken image into a timing regression nobody attributed, and made "which path lowered this body" unanswerable from outside. + +**Contract constants inside the transform, trusting tree shaking.** The transform functions did shake out, but `acorn` declares no `sideEffects`, so the barrel edge alone carried the whole parser into the worker bundle. + +**A separate preview template.** The retired `preview.html` template duplicated the served document and drifted (language, title, entry wiring). Deriving the page from the built index at `closeBundle` removes the second document entirely. + +**Gating the stock entry on top-level await ordering instead of a deferred.** Sibling module scripts do not wait for one another's top-level awaits; the `??=`-installed deferred makes the handshake order-independent and lets a failed handshake reject into the boot page's failure rendering. + +## Consequences + +- `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed). +- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts. +- The served `` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base. +- The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md new file mode 100644 index 0000000000..24ff21957c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md @@ -0,0 +1,34 @@ +# Agent Note:pack 期 lowering 与单构建 preview + +状态:已实施 + +[English](2026-08-20-webworker-pack-lowering-and-preview.md) | 中文 + +## 问题 + +浏览器 worker 既不能在装载期编译模块,也不能由产品 webserver 提供页面:每个模块体必须以可直接运行的形态到达,页面必须是静态产物。两个面早期都发生过漂移。装载器曾携带一个兜底编译器,于是收集器的缺口表现为「启动变慢」而不是「镜像坏了」——而且 `acorn` 经包 barrel 混进了 `lib/worker.js`,一个只包装预 lowered 模块体的运行时根本不需要解析器。preview 曾是服务页面旁的第二份 HTML 模板,一个 served index 可以悄悄漂离的页面。 + +## 决定 + +**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。 + +**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。 + +两个包以 `@deepseek-ai/dsh-experimental-*` 名义放在 `packages/experimental/`,私有且在官方发布之外。承载产品承诺的边界仍在产品包里:注入表、`__DSH_TRANSPORT__` 与 `/plugins` bundle 字节由 `dsh-host-webserver`、`dsh-client-modules`、`dsh-client-connection` 拥有。 + +## 曾考虑的替代方案 + +**保留装载期转换器作安全网。** 它把坏镜像变成无人归因的耗时回归,并且让「这个模块体是谁 lower 的」从外部不可回答。 + +**契约常量留在转换器里,信任 tree shaking。** 转换函数确实被摇掉了,但 `acorn` 未声明 `sideEffects`,仅 barrel 一条边就把整个解析器带进了 worker bundle。 + +**独立的 preview 模板。** 已退役的 `preview.html` 模板复制了服务文档并发生漂移(语言、标题、入口接线)。在 `closeBundle` 从 built index 派生页面则彻底消灭了第二份文档。 + +**用顶层 await 顺序而非 deferred 去闸标准入口。** 兄弟 module script 互不等待对方的顶层 await;`??=` 安装的 deferred 使握手与求值顺序无关,且失败的握手能 reject 进 boot 页的失败呈现。 + +## 后果 + +- `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。 +- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。 +- served 的 `` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。 +- 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。 diff --git a/apps/web/package.json b/apps/web/package.json index bef80ee0a9..23e15a5821 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,12 +17,16 @@ }, "files": [ "dist", - "!dist/**/*.map" + "!dist/**/*.map", + "!dist/preview.html", + "!dist/preview" ], "scripts": { "build": "vite build", "dev": "vite", - "watch": "vite build --watch --no-emptyOutDir" + "watch": "vite build --watch --no-emptyOutDir", + "build:preview": "vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz", + "serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1" }, "license": "MIT", "devDependencies": { @@ -33,16 +37,19 @@ "@deepseek-ai/dsh-client-web": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^", + "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@vitejs/plugin-react": "^4.0.0", + "http-server": "^14.1.1", + "fflate": "^0.8.2", "playwright": "^1.49.0", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^6.0.3", "vite": "^6.0.0", - "vitest": "^4.1.8", - "fflate": "^0.8.2" + "vitest": "^4.1.8" } } diff --git a/apps/web/src/preview.ts b/apps/web/src/preview.ts new file mode 100644 index 0000000000..586cbcab5d --- /dev/null +++ b/apps/web/src/preview.ts @@ -0,0 +1,12 @@ +/** + * Worker-preview bootstrap: the one module preview.html adds ahead of the + * stock entry tag. Connecting the worker host installs the boot globals and + * settles `__DSH_BOOT_READY__`, where the stock entry's pre-boot await holds, + * so everything after this module is the served startup chain verbatim. A + * failed handshake rejects the deferred into the boot page's failure + * rendering; this module owns no page painting. + */ +import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker' +import { connectWorkerHost, IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime/client' + +await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image: `preview/${IMAGE_FILE_NAME}` }) diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts new file mode 100644 index 0000000000..b2d6add83a --- /dev/null +++ b/apps/web/tests/preview-boot.e2e.ts @@ -0,0 +1,242 @@ +/** + * Preview acceptance: the browser-only worker deployment boots the real Cordis + * tree out of the packed VFS image and reaches an interactive page. + * + * `dist/preview.html` is the served page plus one bootstrap script tag, so this + * run exercises the shipped startup chain: the worker mounts the image, + * activates the tree, and answers the page's tunnel until the client settles. + * Two milestones prove that happened — the host's `tree active` boot line, + * whose lowering contract must be the one this checkout's packer emits, and the + * workspace hero, which paints only after the client tree comes up over the + * tunnel. + * + * The site is served the way a static host serves it: bytes from `dist/` with + * no rewrite rules, so a missing file is a 404 rather than the index page. + */ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { tmpdir } from 'node:os' +import { extname, join, normalize } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import type { Browser } from 'playwright' +import { expect, it } from 'vitest' +import { + composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT, +} from '@deepseek-ai/dsh-experimental-webworker-packer' +import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime' +import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' + +const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) + +/** Where the client looks for the image: the runtime's own name, beside the page. */ +const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME) + +/** Profile the preview deployment composes; `build:preview` packs the same one. */ +const PROFILE = 'web' + +/** Pages the preview needs; the Vite build emits both. */ +const PAGES = ['index.html', 'preview.html'] + +/** + * Content types the preview loads. Anything else is served as opaque bytes. + * + * The image goes out as `application/gzip` with no `content-encoding`: the + * worker inflates the gzip member itself, so a transport-decoded body would + * leave its `DecompressionStream('gzip')` with plain tar bytes to inflate. + */ +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.gz': 'application/gzip', + '.webmanifest': 'application/manifest+json', + '.woff2': 'font/woff2', +} + +/** Boot line the worker host writes once its tree finished activating. */ +const TREE_ACTIVE = 'webworker host: tree active' + +/** Image fetch, mount, and tree activation on a loaded machine. */ +const BOOT_TIMEOUT_MS = 240_000 + +/** Client tree settle after the tunnel starts answering. */ +const HERO_TIMEOUT_MS = 240_000 + +/** One served origin over `dist/`. */ +interface Site { + readonly origin: string + /** Release the port; call after the browser is gone. */ + close(): Promise +} + +/** + * Fail before the browser opens a page the build never produced. + * @throws When either preview page is missing from `dist/`. + */ +function requirePreviewPages(): void { + for (const page of PAGES) { + if (existsSync(join(DIST_ROOT, page))) continue + throw new Error(`preview boot needs apps/web/dist/${page} — run \`pnpm run build\` from the repository root`) + } +} + +/** + * The image file to serve, packed here when `dist/` carries none: `pnpm run + * build` emits the pages but only `build:preview` packs, so this lane packs + * for itself rather than skipping the deployment it is here to accept. An + * image already in place is used as it stands — the worker refuses one lowered + * against another wrapper contract, and that refusal names the rebuild. A + * self-packed image lands in a temp directory, never in `dist/`: the + * client-artifact digest record treats `dist/` as build-owned, so a test write + * there fails the record check for every later consumer. + * @returns The file to answer `preview/` with, and its teardown. + * @throws When the closure leaves dependencies unresolved, which would pack an + * incomplete image the tree fails on later and further from the cause. + */ +function requireVfsImage(): { path: string; cleanup(): void } { + if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} } + const packed = packVfsImage({ + config: composeProfile(REPO_ROOT, PROFILE), + profile: PROFILE, + workspaces: indexWorkspacePackages(REPO_ROOT), + resolveFrom: REPO_ROOT, + configTrees: configTrees(REPO_ROOT), + }) + if (packed.missing.length > 0) { + throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`) + } + const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-')) + const path = join(directory, IMAGE_FILE_NAME) + writeFileSync(path, packed.image) + return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } } +} + +/** + * Answer one request with the file it names under `dist/`; the image path + * answers from wherever {@link requireVfsImage} put the file. + * @param request - Incoming request; only its path is read. + * @param response - Response to write the bytes or the 404 to. + * @param imagePath - File behind `preview/`. + */ +async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '') + try { + const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative)) + response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' }) + response.end(body) + } catch { + // A miss is a miss: the deployment has no SPA fallback, and hiding one + // behind the index page would make a broken asset URL look like a boot + // failure. + response.writeHead(404) + response.end(`not found: ${relative}`) + } +} + +/** + * Serve `dist/` over loopback with static-host semantics. + * @param imagePath - File behind `preview/`. + * @returns The origin to navigate, and its teardown. + */ +async function serveDist(imagePath: string): Promise { + const server = createServer((request, response) => { void respond(request, response, imagePath) }) + await new Promise((listening) => { server.listen(0, '127.0.0.1', listening) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port') + return { + origin: `http://127.0.0.1:${String(address.port)}`, + close: async () => { + server.closeAllConnections() + await new Promise((closed, reject) => { + server.close((error) => { + if (error === undefined) closed() + else reject(error) + }) + }) + }, + } +} + +/** + * Bound one boot milestone so a stall names the milestone instead of surfacing + * as the lane's generic test timeout. + * @param work - The milestone to wait for. + * @param ms - How long it may take. + * @param stalled - Error message when it does not arrive in time. + * @returns What `work` resolved to. + */ +async function within(work: Promise, ms: number, stalled: string): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + work, + new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error(stalled)) }, ms) }), + ]) + } finally { + clearTimeout(timer) + } +} + +it('boots the packed worker deployment to an interactive page', async () => { + requirePreviewPages() + const image = requireVfsImage() + try { + const site = await serveDist(image.path) + try { + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }) + try { + await bootPreview(site.origin, browser) + } finally { + await browser.close() + } + } finally { + await site.close() + } + } finally { + image.cleanup() + } +}, 600_000) + +/** + * Open the preview page and hold it to both boot milestones. + * @param origin - Origin serving `dist/`. + * @param browser - Browser to open the page in. + */ +async function bootPreview(origin: string, browser: Browser): Promise { + const page = await newEnglishPage(browser) + const pageErrors: Error[] = [] + page.on('pageerror', (error) => { pageErrors.push(error) }) + // Registered before navigation: the worker reports its tree long before the + // tunnel serves the client, so a listener added later would miss the line. + const treeActive = new Promise((reported) => { + page.on('console', (message) => { + const text = message.text() + if (text.includes(TREE_ACTIVE)) reported(text) + }) + }) + try { + await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' }) + const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`) + // The activated tree ran bodies lowered against the contract this + // checkout's packer emits; a dist built before a contract change would + // report the older one. + expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) + // The hero's workspace picker is the client tree's first interactive + // surface, so it appears only once the startup chain completed over the + // tunnel. + await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + expect(pageErrors.map(error => error.message)).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'preview-boot') + throw pageErrors.length === 0 + ? error + : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors') + } +} diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts index fe97e42da9..08e210fa26 100644 --- a/apps/web/tests/pwa-manifest.e2e.ts +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -7,7 +7,7 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) it('ships install metadata with the built web application', async () => { const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') - expect(index).toContain('') + expect(index).toContain('') const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) expect(manifest).toEqual({ diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 38a0438ef9..bbad4aadd9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -49,6 +49,7 @@ "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", "tests/hmr-live.e2e.ts", + "tests/preview-boot.e2e.ts", "tests/seeded-history.e2e.ts", "tests/cold-blank-session.e2e.ts", "tests/stats-paged-history.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cd27136cb7..dff22a99ec 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,4 @@ +import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' import type { Plugin } from 'vite' @@ -36,6 +37,35 @@ function rejectStandaloneServe(): Plugin { } } +/** + * Emit preview.html beside index.html: the built index page with one module + * script — the worker bootstrap entry — spliced ahead of its entry tag. Both + * pages share every chunk; the extra tag is the only difference, so the + * static worker deployment ships the served page verbatim plus its + * bootstrap. + */ +function emitPreviewPage(): Plugin { + let bootstrapFile: string | undefined + return { + name: 'dsh-emit-preview-page', + generateBundle(_options, bundle) { + for (const item of Object.values(bundle)) { + if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName + } + if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle') + }, + async closeBundle() { + // A build that failed before generateBundle has no page to splice. + if (bootstrapFile === undefined) return + const page = await readFile(src('./dist/index.html'), 'utf8') + const anchor = page.indexOf('` + await writeFile(src('./dist/preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`) + }, + } +} + /** * Vendor-chunk membership, by exact npm package name — the heavy render * families (math, highlight, markdown) that change only on dependency bumps. @@ -108,11 +138,30 @@ function npmPackageOf(id: string): string | undefined { } export default defineConfig({ - plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()], + // Relative asset URLs: preview.html mounts the same output under any base + // directory, and the served index resolves identically from the site root. + base: './', + plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()], build: { + // The worker bootstrap holds its page at top-level await; Vite's default + // `modules` target (es2020-era) rejects that syntax. + target: 'es2022', sourcemap: true, rollupOptions: { + input: { + index: src('./index.html'), + // Standalone entry, not an index.html script tag: Vite folds every + // module tag of one page into a single synthetic entry, and only a + // separate input keeps the shared page chunks bootstrap-free. + bootstrap: src('./src/preview.ts'), + }, output: { + // The worker-preview surface groups under dist/preview/ (the page + // itself stays at dist/preview.html), so the published payload can + // exclude it as one directory. + entryFileNames(chunk): string { + return chunk.name === 'bootstrap' ? 'preview/[name]-[hash].js' : 'assets/[name]-[hash].js' + }, // Output layout: the two main chunks stay at assets/ root; lazy // @shikijs/langs grammar chunks group under assets/langs/; fonts // (all KaTeX faces referenced by vendor.css) group under @@ -144,6 +193,10 @@ export default defineConfig({ }, }, }, + worker: { + // The preview worker rides dist/preview/ with the rest of that surface. + rollupOptions: { output: { entryFileNames: 'preview/[name]-[hash].js' } }, + }, resolve: { // One instance per shared npm identity: a bare specifier otherwise resolves // from the importer's directory, so a diverging range ships a second React diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 1afd319906..1227299362 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -44,6 +44,10 @@ const MIME: Record = { '.json': 'application/json', '.map': 'application/json', '.webmanifest': 'application/manifest+json', + // The packed VFS image. Served as its own bytes, never as a Content-Encoding: + // the worker inflates the body itself, and a transport-level encoding would + // leave it inflating an already-decoded archive. + '.gz': 'application/gzip', } const STATIC_MISS_CODES: ReadonlySet = new Set([ @@ -104,8 +108,14 @@ export async function serveStatic( export function apply(ctx: Context, config: Config): void { const distIndex = config.distIndex const distRoot = dirname(distIndex) - const renderIndex = async (): Promise => - ctx.webServer.renderIndex(await readFile(distIndex, 'utf8')) + // The dist is built with a relative base so the same files mount under any + // static directory; served pages also answer deep SPA-fallback paths, where + // relative asset URLs would resolve under the request directory, so the + // served form anchors them at the site root ahead of every URL-bearing tag. + const renderIndex = async (): Promise => { + const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8')) + return body.replace(/]*)?>/i, open => `${open}`) + } ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => { // Non-GET/HEAD without a matching named route is 405 (fallback-only // semantics: named routes own their method handling). diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index fda9dcbc3e..93989857b9 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -80,7 +80,9 @@ async function request(port: number, path: string, init?: RequestInit): Promise< return { status: response.status, type: response.headers.get('content-type'), - body: (await response.text()).slice(0, 80), + // Window wide enough to keep index body markers visible behind the + // served prelude (base anchor + injection rows + boot-readiness tail). + body: (await response.text()).slice(0, 200), } } diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 314565fe54..336589bd2d 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -58,9 +58,10 @@ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|app const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { '@deepseek-ai/dsh': ['lib/*.js', 'config'], - // The Web build emits sourcemaps for browser debugging; publishing them is - // what the payload policy forbids, so the bundle ships without them. - '@deepseek-ai/dsh-web-frontend': ['dist', '!dist/**/*.map'], + // Sourcemaps stay out by payload policy; the worker-preview surface + // (dist/preview.html and dist/preview/) backs private experimental + // packages and is not published. + '@deepseek-ai/dsh-web-frontend': ['dist', '!dist/**/*.map', '!dist/preview.html', '!dist/preview'], } /** The subset of package.json fields this constraint check cares about. */ diff --git a/tsconfig.host.json b/tsconfig.host.json index 65de682575..687a5fda1b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -36,6 +36,7 @@ "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", + "apps/web/tests/preview-boot.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/cold-blank-session.e2e.ts", "apps/web/tests/stats-paged-history.e2e.ts", From 3cc90952ccca904dc03d9d07a97293511a06dbbe Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:20:53 +0800 Subject: [PATCH 068/248] chore(gates): regenerate catalogs and keep repository gates green Config catalog, module graph, event producer-consumer tables, and third-party notices regenerate over the webworker surface; the oxlint rule fingerprint and the ui-renderer NodeNext import face follow. --- .oxlintrc.json | 15 +++++++++++++++ docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 4 +++- docs/config-catalog.zh.md | 4 +++- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 4 ++-- docs/event-producer-consumer.zh.md | 4 ++-- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 9 +++++++++ docs/module-graph.zh.md | 9 +++++++++ packages/client/ui-renderer/package.json | 3 ++- packages/client/ui-renderer/src/client/bind.ts | 5 ++++- pnpm-lock.yaml | 3 +++ scripts/lint-rule-fingerprint.spec.ts | 2 +- 14 files changed, 59 insertions(+), 15 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 70f53fd4cd..6ec1ad0d82 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -316,6 +316,21 @@ "rules": { "@stylistic/quotes": "off" } + }, + { + "files": [ + "packages/experimental/webworker-runtime/src/node/**/*.ts", + "packages/experimental/webworker-runtime/src/storage/memory.ts", + "packages/experimental/webworker-runtime/src/module-system/module-loader.ts", + "packages/experimental/webworker-runtime/src/transport/synthetic-http.ts" + ], + "rules": { + "typescript/require-await": "off", // Async faces Node and Cordis define (fs promises, the module seam) reject rather than throw; the VFS beneath them never awaits. + "typescript/no-extraneous-class": "off" // Node constructs these (`new Script()`, `new Worker()`), so a stub that refuses must still be a class. + }, + "plugins": [ + "typescript" + ] } ] } diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d0665a143e..5c189f2840 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: de340b7ffade528301b4538b0553bc11ec969985 -config-catalog.zh.md: eb17ee89fd7860cc0774073bea542aca315ce652 +config-catalog.md: 1efed4bd0b0b097cc5ab60c594403584b01c8032 +config-catalog.zh.md: 520f5b834fd2c848c5b21fb17d15f2aafb026d1d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index de340b7ffa..1efed4bd0b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3084,7 +3084,7 @@ export interface Config { } ``` -Source: [`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) @@ -3331,6 +3331,8 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-code-runtime-python` ([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) +- `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) +- `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths` ([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-launch-environment` ([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eb17ee89fd..520f5b834f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3086,7 +3086,7 @@ export interface Config { } ``` -来源:[`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) @@ -3332,6 +3332,8 @@ export interface Config { - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-code-runtime-python`([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) +- `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts)) +- `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths`([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-launch-environment`([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index c37d706383..4f4df86e41 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 1fb65d55f5a0d8121f4f171c956196fde746103f -event-producer-consumer.zh.md: d8db21e5266f83a5fc403a9b825bc05530e61b8d +event-producer-consumer.md: 52a8003beb55c178f2ae7513f2b21d3a6c686b2b +event-producer-consumer.zh.md: c66e07657ec3be4bd4ce1c461782a04f1b1edf8f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1fb65d55f5..52a8003beb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -59,7 +59,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -72,7 +72,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `webserver` | +| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index d8db21e526..c66e07657e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -61,7 +61,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -74,7 +74,7 @@ | 事件字符串 | 派发方 | 监听方 | | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `webserver` | +| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 78bd38fd5e..94bda85880 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 531def00118795d3d8c6812baa215e71ca499bc0 -module-graph.zh.md: 6d6794da2f1545c7d420f4c4f161c2711143b16c +module-graph.md: 7ffc7137d90bd56af1447523b1abfccce5b02947 +module-graph.zh.md: 0a7cacdf58562ac768469abba4d7910398151a1b diff --git a/docs/module-graph.md b/docs/module-graph.md index 531def0011..7ffc7137d9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -196,6 +196,8 @@ flowchart TD subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_experimental_webworker_packer["experimental-webworker-packer"] + pkg_experimental_webworker_runtime["experimental-webworker-runtime"] end subgraph group_extensions["packages/extensions"] pkg_client_ui_cordis["client-ui-cordis"] @@ -351,6 +353,7 @@ flowchart TD pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants pkg_sdk_jsonrpc_demo --> pkg_invariants + pkg_experimental_webworker_packer --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -1151,6 +1154,10 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1480,6 +1487,7 @@ flowchart TD | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1644,6 +1652,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6d6794da2f..0a7cacdf58 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -198,6 +198,8 @@ flowchart TD subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_experimental_webworker_packer["experimental-webworker-packer"] + pkg_experimental_webworker_runtime["experimental-webworker-runtime"] end subgraph group_extensions["packages/extensions"] pkg_client_ui_cordis["client-ui-cordis"] @@ -353,6 +355,7 @@ flowchart TD pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants pkg_sdk_jsonrpc_demo --> pkg_invariants + pkg_experimental_webworker_packer --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -1153,6 +1156,10 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1482,6 +1489,7 @@ flowchart TD | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1646,6 +1654,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index b6fbf0a9f7..4c21e3063b 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -52,13 +52,14 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", + "@types/use-sync-external-store": "^1.5.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-renderer/src/client/bind.ts b/packages/client/ui-renderer/src/client/bind.ts index 7d72eced6c..0088ac8c68 100644 --- a/packages/client/ui-renderer/src/client/bind.ts +++ b/packages/client/ui-renderer/src/client/bind.ts @@ -4,7 +4,10 @@ * This is the ONE hook constructor in the client stack — engines and hosts * traffic in bare sources; binding happens on the React side. */ -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js' +// Extensionless on purpose: the runtime package has no exports map, so both +// bundler and NodeNext resolution accept this form, while `@types/…` exposes +// only the extensionless subpath under its exports. +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b5d260f82..13ba79ef9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4226,6 +4226,9 @@ importers: '@deepseek-ai/dsh-experimental-webworker-runtime': specifier: workspace:^ version: link:../webworker-runtime + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths js-yaml: specifier: ^4.2.0 version: 4.3.1 diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts index 0db617ba59..ffe079e5ab 100644 --- a/scripts/lint-rule-fingerprint.spec.ts +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -85,7 +85,7 @@ describe('Oxlint repository rule fingerprint', () => { const overrides: readonly unknown[] = parsed.overrides it('pins every override field', () => { - expect(overrides).toHaveLength(8) + expect(overrides).toHaveLength(9) }) it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => { From 3a47674798af23d9d0ac3080a86e7ad8aaf7d4e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:25:47 +0800 Subject: [PATCH 069/248] ci: add build-preview workflow --- ...-preview-cloudflare-pages-deploy.i18n.yaml | 6 + ...6-08-20-preview-cloudflare-pages-deploy.md | 27 +++ ...8-20-preview-cloudflare-pages-deploy.zh.md | 27 +++ .../workflows/build-preview-cloudflare.yml | 166 ++++++++++++++++++ packages/experimental/webworker-packer/bin.js | 23 +++ .../webworker-packer/package.json | 3 +- scripts/check-workspace-constraints.ts | 5 +- 7 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md create mode 100644 .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md create mode 100644 .github/workflows/build-preview-cloudflare.yml create mode 100644 packages/experimental/webworker-packer/bin.js diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml new file mode 100644 index 0000000000..83f673d68e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.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 .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md +2026-08-20-preview-cloudflare-pages-deploy.md: 38b2834d612153d235d3b0aaffead3bd2b33eec7 +2026-08-20-preview-cloudflare-pages-deploy.zh.md: 3ebb8eba95666729ee1021ffe2c958dad40aed1c diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md new file mode 100644 index 0000000000..38b2834d61 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md @@ -0,0 +1,27 @@ +# Agent Note: per-PR preview deployments on Cloudflare Pages + +Status: implemented + +English | [中文](2026-08-20-preview-cloudflare-pages-deploy.zh.md) + +## Problem + +The browser worker preview exists to observe a pull request's frontend and host code running, so it needs a static host per pull request that outsiders cannot reach. GitHub Pages publishes privately only on GitHub Enterprise Cloud, which this organization has not settled, and one Pages site per repository cannot isolate pull requests. The first deployment run also exposed a packaging defect: on a clean checkout `pnpm install` never creates the `dsh-pack-vfs-image` bin link, so `build:preview` fails with `command not found` anywhere but a working tree whose install ran after a build. + +## Decision + +**Deployment.** Every push to a pull request publishes `apps/web/dist` to the Cloudflare Pages project `dsh-build-preview` under the branch alias `pr-`, behind Cloudflare Access (`.github/workflows/build-preview-cloudflare.yml`). The upload carries build products only — the platform never holds repository sources, and sourcemaps are deleted before upload because they embed complete sources. `preview.html` replaces `index.html` as the deployment root: the served page cannot boot without a host injecting `window.__DSH_BOOT__`, so the root must be the page that boots. Per pull request the newest build wins; across pull requests each alias is its own URL, so nothing contends. The run passes only after a service-token request proves the protected URL serves the packed image: HTTP 200 (Access admitted the token; 302 means the Access policy lacks its Service Auth rule), no `content-encoding` (the platform must not claim transport compression over an already-compressed body, which would leave the worker's `DecompressionStream` inflating a plain tar), and the gzip magic `1f 8b`. A marker-guarded comment states the stable alias URL once per pull request. + +**Bin link.** pnpm creates a workspace bin link only when the link target exists at install time. A `bin` entry naming a build product (`lib/bin.js`) therefore never gets its link on a clean checkout — building later does not revisit linking. The packer commits a root `bin.js` as the stable link target; it forwards to `lib/bin.js` and, when the build product is missing, names `pnpm run build` and exits 1. Same pattern as `dsh-subprocess-local`'s committed spawn-helper entry. + +## Alternatives considered + +**GitHub Pages, privately published.** Enterprise-Cloud-only, and `deploy-pages` replaces the whole site, so pull requests would overwrite each other; per-branch subdirectories require the legacy branch-deploy path and its build-rate limits. + +**Actions artifact as the preview.** Download permission aligns exactly with repository read access and costs nothing, but an artifact is a zip download, not a browsable site. Kept as the fallback if the Cloudflare surface goes away. + +**Documenting "install again after building" instead of committing a link target.** Leaves every clean checkout broken in an order-dependent way the error message does not explain; CI is precisely such a checkout on every run. + +## Consequences + +A pull request's preview lives at `https://pr-.dsh-build-preview.pages.dev` and demands a Cloudflare Access sign-in; automation reaches it with a service token. The deployment platform holds no sources and no sourcemaps, which also means the preview cannot map its bundles back to source until sourcemap handling is designed deliberately. The image byte path — bytes stored compressed, served without transport re-encoding — is asserted on every deployment, so a platform behavior change fails the run instead of the worker boot. The packer bin works from any clean checkout after one full build, and the constraints table pins `bin.js` in the published file list. diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md new file mode 100644 index 0000000000..3ebb8eba95 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md @@ -0,0 +1,27 @@ +# Agent Note:每 PR 预览部署上 Cloudflare Pages + +状态:已实现 + +[English](2026-08-20-preview-cloudflare-pages-deploy.md) | 中文 + +## 问题 + +浏览器 worker 预览的存在意义是观察某个 pull request 的前端与 host 代码运行态,因此需要一个外人无法访问的、按 pull request 隔离的静态托管。GitHub Pages 的私有发布只在 GitHub Enterprise Cloud 上可用,而本组织尚未定夺;且一个仓库一个 Pages 站点无法隔离多个 pull request。首次部署运行还暴露了一个打包缺陷:干净 checkout 上 `pnpm install` 永远不会创建 `dsh-pack-vfs-image` 的 bin 链接,`build:preview` 在任何「install 不是在 build 之后跑的」工作树上都以 `command not found` 失败。 + +## 决定 + +**部署。**pull request 的每次推送把 `apps/web/dist` 发布到 Cloudflare Pages 项目 `dsh-build-preview` 的分支别名 `pr-` 下,置于 Cloudflare Access 之后(`.github/workflows/build-preview-cloudflare.yml`)。上传只携带构建产物——平台永远拿不到仓库源码,sourcemap 因内嵌完整源码在上传前删除。`preview.html` 顶替 `index.html` 成为部署根:served 页面没有 host 注入 `window.__DSH_BOOT__` 就无法启动,所以根必须是能启动的那张页。同一 pull request 内最新构建胜出;不同 pull request 各占各的别名 URL,互不争抢。运行只有在 service token 请求证明受保护 URL 真的送达打包镜像后才算通过:HTTP 200(Access 放行了该 token;302 意味着 Access 策略缺 Service Auth 规则)、无 `content-encoding`(平台不得对已压缩的 body 声明传输压缩,否则 worker 的 `DecompressionStream` 会对着解开的裸 tar 充气)、gzip 魔数 `1f 8b`。带标记守卫的评论对每个 pull request 只报一次稳定别名 URL。 + +**bin 链接。**pnpm 只在链接目标于 install 时已存在的情况下创建 workspace bin 链接。`bin` 指向构建产物(`lib/bin.js`)因此在干净 checkout 上永远得不到链接——事后构建不会补建链接。packer 在包根提交 `bin.js` 作为稳定链接目标;它转发到 `lib/bin.js`,构建产物缺失时点名 `pnpm run build` 并以 1 退出。与 `dsh-subprocess-local` 提交 spawn-helper 入口是同一模式。 + +## 曾考虑的替代方案 + +**GitHub Pages 私有发布。**Enterprise Cloud 独占,且 `deploy-pages` 整站替换,多个 pull request 会互相覆盖;按分支子目录要走遗留的分支部署通道并吃其构建频率限制。 + +**用 Actions artifact 当预览。**下载权限与仓库 read 权限逐字对齐、零成本,但 artifact 是 zip 下载不是可浏览的站点。留作 Cloudflare 面失效时的兜底。 + +**用「build 之后再 install 一次」的文档说明代替提交链接目标。**让每个干净 checkout 都以一种错误信息解释不了的、依赖顺序的方式坏掉;CI 每次运行恰恰就是这样的 checkout。 + +## 后果 + +pull request 的预览位于 `https://pr-.dsh-build-preview.pages.dev`,访问要求 Cloudflare Access 登录;自动化用 service token 通行。部署平台不持有源码与 sourcemap,这也意味着在 sourcemap 处理被专门设计之前,预览无法把 bundle 映射回源码。镜像的字节通路——压缩存储、无传输再编码送达——在每次部署时被断言,平台行为变化会让运行失败而不是让 worker 启动失败。packer bin 在任何干净 checkout 上一次完整构建后即可用,constraints 表把 `bin.js` 钉进发布文件清单。 diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml new file mode 100644 index 0000000000..1c9ef206c3 --- /dev/null +++ b/.github/workflows/build-preview-cloudflare.yml @@ -0,0 +1,166 @@ +name: Build PR preview + +# Every push to a pull request publishes that pull request's preview to +# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The +# upload carries build products only: the workflow never grants the deployment +# platform access to this repository's sources. + +on: + pull_request: + types: [opened, synchronize, reopened] + +# Within one pull request the newest build wins. Across pull requests there is +# nothing to serialize: each uploads to its own branch alias, so two deployments +# never contend for the same URL. +concurrency: + group: build-preview-cloudflare-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +env: + PRIMARY_NODE_VERSION: '24' + # Cloudflare Pages project receiving the upload. Its preview deployments are + # the surface the Access application protects; the project's production branch + # is deliberately a name no deployment uses, so no unprotected URL exists. + CF_PROJECT: dsh-build-preview + # CI runs must never report to the production telemetry endpoint baked into + # apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + +jobs: + preview: + runs-on: dsh-ubuntu-24-04-16core + name: cloudflare pages preview + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Read-only: the preview lane consumes the default-branch cache without + # putting cache upload on its own path. + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # apps/web consumes workspace packages as built lib products, and + # build:preview packs the image through the packer's installed bin + # (lib/bin.js), so neither half exists before the full build runs. + - name: Build workspace + run: pnpm run build + + - name: Build the preview page and pack the VFS image + env: + DSH_CLIENT_TITLE: DSH preview pr-${{ github.event.pull_request.number }} + run: pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview + + # Sourcemaps carry complete sources and stay off the deployment platform. + # index.html is the served page, which cannot boot without a host + # injecting window.__DSH_BOOT__; replacing it with the worker page makes + # the deployment root the usable entry instead of a page that never boots. + - name: Shape the upload + run: | + find apps/web/dist -name '*.map' -delete + cp apps/web/dist/preview.html apps/web/dist/index.html + + - name: Upload to Cloudflare Pages + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx --yes wrangler@4 pages deploy apps/web/dist \ + --project-name "$CF_PROJECT" \ + --branch "pr-${{ github.event.pull_request.number }}" \ + --commit-dirty=true + + # The image is what a worker boot fails on first and least visibly, so the + # run only passes once the protected URL serves it as gzip bytes. Three + # facts are asserted, each with its own failure meaning: + # 200 Access admitted the request; a 302 means the + # Access policy is missing its Service Auth rule + # for this token + # no content-encoding the platform did not claim transport + # compression, which would make the browser + # decode the body and leave the worker's + # DecompressionStream inflating a plain tar + # gzip magic 1f 8b the bytes really are the gzip member the + # packer wrote + # Accept-Encoding is sent because a browser sends it; the assertion is + # about what the platform does with a body that is already compressed. + - name: Verify the protected deployment serves the image + env: + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + run: | + url="https://pr-${{ github.event.pull_request.number }}.${CF_PROJECT}.pages.dev" + image="$url/preview/vfs-image.tar.gz" + code=000 + for attempt in 1 2 3 4 5; do + code=$(curl -sS -o image.bin -D headers.txt -w '%{http_code}' \ + -H 'Accept-Encoding: gzip' \ + -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ + "$image" || echo 000) + echo "attempt $attempt: HTTP $code" + if [ "$code" = "200" ]; then break; fi + sleep 10 + done + if [ "$code" != "200" ]; then + echo "the protected image URL answered $code, not 200" + head -20 headers.txt + exit 1 + fi + if grep -qi '^content-encoding:' headers.txt; then + echo "the platform declared transport compression on an already-compressed image:" + grep -i '^content-encoding:' headers.txt + exit 1 + fi + magic=$(head -c 2 image.bin | od -An -tx1 | tr -d ' \n') + if [ "$magic" != "1f8b" ]; then + echo "image does not start with the gzip magic number: $magic" + exit 1 + fi + echo "image served as $(wc -c < image.bin) gzip bytes" + + # The alias URL follows from the pull request number, so it is stable + # across redeploys and worth stating once. The marker makes the comment + # idempotent: a pull request opened before this workflow existed never + # sees an `opened` event, and every later push must not restate the URL. + - name: Comment the preview URL + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + marker='' + existing=$(gh pr view "$PR" --json comments \ + --jq "[.comments[] | select(.body | contains(\"$marker\")) | .url] | first // empty") + if [ -n "$existing" ]; then + echo "preview URL already commented: $existing" + exit 0 + fi + gh pr comment "$PR" --body \ + "$marker \n [Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" diff --git a/packages/experimental/webworker-packer/bin.js b/packages/experimental/webworker-packer/bin.js new file mode 100644 index 0000000000..36d1d22fb4 --- /dev/null +++ b/packages/experimental/webworker-packer/bin.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +/** + * Stable link target for the `dsh-pack-vfs-image` bin, forwarding to the build + * product. + * + * pnpm creates a workspace package's bin link only when the link target exists + * at install time. Pointing the bin straight at `lib/bin.js` — a build product — + * left the link uncreated on every clean checkout, so the command was missing + * from `node_modules/.bin` even after a build produced the file, and only an + * install that happened to follow a build brought it back. This file is + * committed, so the link is always created; the build product is resolved when + * the command actually runs. + * @module @deepseek-ai/dsh-experimental-webworker-packer/bin + */ +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const entry = new URL('./lib/bin.js', import.meta.url) +if (!existsSync(fileURLToPath(entry))) { + process.stderr.write('dsh-pack-vfs-image: lib/bin.js is missing — run `pnpm run build` before packing an image\n') + process.exit(1) +} +await import(entry.href) diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 6e37359454..f61111d905 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -12,7 +12,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "bin": { - "dsh-pack-vfs-image": "./lib/bin.js" + "dsh-pack-vfs-image": "./bin.js" }, "exports": { ".": { @@ -30,6 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", + "bin.js", "lib/repository-*.js", "lib/types/**/*.d.ts" ], diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 336589bd2d..e052a9a2b0 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -163,8 +163,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-session-persistence-sqlite': ['resources/sql/**/*.sql'], '@deepseek-ai/dsh-skill-badge': ['assets'], // tsdown shares the repository/pack code between the lib entry and the bin - // through a hashed chunk. - '@deepseek-ai/dsh-experimental-webworker-packer': ['lib/repository-*.js'], + // through a hashed chunk. The committed bin.js is the link target pnpm can + // resolve at install time, before the build produces lib/bin.js. + '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], } From 304b4b8424a6721cc4f272ff5f76119ab4bc467f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:00:48 +0800 Subject: [PATCH 070/248] docs: localize a cross-note link in the composer edit-range note --- .github/workflows/build-preview-cloudflare.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml index 1c9ef206c3..c98bae775c 100644 --- a/.github/workflows/build-preview-cloudflare.yml +++ b/.github/workflows/build-preview-cloudflare.yml @@ -163,4 +163,5 @@ jobs: exit 0 fi gh pr comment "$PR" --body \ - "$marker \n [Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" + "$marker \ + [Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" From 99db143e3748d7d289b166a23b7ee3d12f9ecc56 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:55:06 +0800 Subject: [PATCH 071/248] feat(webworker): name packed modules and client bundles for the debugger --- apps/web/package.json | 2 +- .../experimental/webworker-packer/src/pack.ts | 56 +++++++++++++++++-- .../webworker-packer/src/rules.ts | 7 ++- .../tests/image-loadable.spec.ts | 21 +++++++ .../webworker-runtime/src/client/client.ts | 3 + 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 23e15a5821..5bdfe8ebd7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,7 @@ "build": "vite build", "dev": "vite", "watch": "vite build --watch --no-emptyOutDir", - "build:preview": "vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz", + "build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz", "serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1" }, "license": "MIT", diff --git a/packages/experimental/webworker-packer/src/pack.ts b/packages/experimental/webworker-packer/src/pack.ts index 2f71716e4d..a5dcb00156 100644 --- a/packages/experimental/webworker-packer/src/pack.ts +++ b/packages/experimental/webworker-packer/src/pack.ts @@ -105,7 +105,7 @@ export interface PackResult { readonly missing: readonly string[] /** Executable scripts dropped from the image. */ readonly executables: readonly string[] - /** Page bundles left verbatim, and so out of the transform. */ + /** Page bundles left out of the transform; like every JavaScript entry they carry the trailing debugger name. */ readonly pageBundles: readonly string[] /** JavaScript entries the image carries. */ readonly javascriptEntries: number @@ -282,6 +282,53 @@ interface SweepOutcome { * @param root - Virtual root the candidates mount under. * @returns The final entries plus the sweep's counts. */ +/** Trailing `sourceMappingURL` comment; the image carries no `.map` files. */ +const DANGLING_SOURCE_MAP = /\n\/\/# sourceMappingURL=\S+\s*$/ + +/** + * Name one JavaScript entry for the debugger: append the `sourceURL` magic + * comment V8 stacks and DevTools read, so the entry shows under its + * repository path instead of as an anonymous VM script (worker `new Function` + * bodies) or blob entry (page bundles). A trailing `sourceMappingURL` comment + * is stripped first — its `.map` never ships, and once the script has a name + * the debugger would resolve the reference against it and report a load + * failure per script. Only the final line is touched, so every other line + * keeps its number; evaluation cost stays at pack time, where the names are + * already deterministic. + * @param bytes - Entry body as the image would otherwise hold it. + * @param name - Debugger name for the entry. + * @param decoder - Shared UTF-8 decoder. + * @param encoder - Shared UTF-8 encoder. + * @returns The named body. + */ +function nameForDebugger(bytes: Uint8Array, name: string, decoder: TextDecoder, encoder: TextEncoder): Uint8Array { + const source = decoder.decode(bytes).replace(DANGLING_SOURCE_MAP, '\n') + return encoder.encode(`${source}\n//# sourceURL=${name}`) +} + +/** + * Debugger names for image entries: a workspace or vendored package file is + * named by its repository path (`packages///lib/index.js`), the + * shape a reader navigates; an external package file keeps its image key — + * it has no repository path, and its pnpm store path would name a hash. + * @param workspaces - Package name → absolute repository directory. + * @param resolveFrom - Repository root the names are relative to. + * @returns Mapper from an image key to the entry's debugger name. + */ +function debuggerNamer(workspaces: ReadonlyMap, resolveFrom: string): (key: string) => string { + const repoDirs = new Map( + [...workspaces].map(([name, directory]) => [name, relative(resolveFrom, directory).replaceAll('\\', '/')]), + ) + return (key: string): string => { + if (!key.startsWith('node_modules/')) return key + const rest = key.slice('node_modules/'.length) + const segments = rest.split('/') + const packageName = segments[0]?.startsWith('@') === true ? segments.slice(0, 2).join('/') : segments[0] ?? '' + const directory = repoDirs.get(packageName) + return directory === undefined ? key : `${directory}${rest.slice(packageName.length)}` + } +} + function sweepImage( files: ImageFiles, options: PackOptions, @@ -317,7 +364,7 @@ function sweepImage( continue } // Every non-wildcard face is a root; a face resolving onto a page asset is - // kept verbatim below rather than excluded here. + // kept untransformed below rather than excluded here. const subpaths = manifest.exports === undefined ? ['.'] : Object.keys(manifest.exports).filter(key => key.startsWith('.') && !key.includes('*')) @@ -379,12 +426,13 @@ function sweepImage( } const swept: ImageFiles = {} + const debuggerName = debuggerNamer(options.workspaces, options.resolveFrom) let javascriptEntries = 0 let dropped = 0 for (const [name, bytes] of Object.entries(files)) { const isJs = /\.[cm]?js$/.test(name) if (!isJs || pageAsset(name)) { - swept[name] = bytes + swept[name] = isJs ? nameForDebugger(bytes, debuggerName(name), decoder, encoder) : bytes if (isJs) javascriptEntries += 1 continue } @@ -393,7 +441,7 @@ function sweepImage( dropped += 1 continue } - swept[name] = kept + swept[name] = nameForDebugger(kept, debuggerName(name), decoder, encoder) javascriptEntries += 1 } return { diff --git a/packages/experimental/webworker-packer/src/rules.ts b/packages/experimental/webworker-packer/src/rules.ts index 96c1fa0265..3e7321f842 100644 --- a/packages/experimental/webworker-packer/src/rules.ts +++ b/packages/experimental/webworker-packer/src/rules.ts @@ -44,9 +44,10 @@ export const EXCLUDE_WORKSPACE: readonly string[] = [ * A package's `lib/client.js` is its browser bundle behind the `./client` * export: the page's own module system evaluates it with its own wrapper, * which has no ambient-store parameter. Transforming those bodies would - * inject calls the page cannot resolve, so they ship verbatim — and the - * manifest's all-or-nothing claim stays true, because the worker loader never - * evaluates them (the tunnel serves them as bytes). + * inject calls the page cannot resolve, so they ship untransformed — their + * only change is the trailing debugger-name line every JavaScript entry + * gains — and the manifest's all-or-nothing claim stays true, because the + * worker loader never evaluates them (the tunnel serves them as bytes). */ export const PAGE_ASSETS: readonly string[] = [ 'node_modules/*/lib/client.js', diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 07ccb0986f..688b21c279 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -75,6 +75,27 @@ const archive = async (): Promise => expect(result.transform.rewritten).toBeGreaterThan(0) }) + it('names every JavaScript entry for the debugger, workspace files by repository path', () => { + const result = packed() + const decoder = new TextDecoder() + const entries = Object.keys(result.files).filter(name => /\.[cm]?js$/.test(name)) + expect(entries.length).toBeGreaterThan(0) + for (const name of entries) { + const lines = decoder.decode(result.files[name]).split('\n') + // V8 stacks and DevTools read the trailing comment, so worker + // `new Function` bodies and page blobs alike show under a stable name + // instead of as anonymous VM or blob entries. + expect(lines.at(-1)).toMatch(/^\/\/# sourceURL=\S+$/) + // A dangling map reference would make the debugger report one load + // failure per named script; the packer ships no `.map` files. + expect(lines.at(-2) ?? '').not.toContain('sourceMappingURL') + } + // A workspace entry is named by the path a reader navigates in this + // repository, not by its image mount. + const subject = decoder.decode(result.files[`node_modules/${SUBJECT}/lib/index.js`]) + expect(subject.endsWith('\n//# sourceURL=packages/util/timeout/lib/index.js')).toBe(true) + }) + it('writes one gzip member whose header records no build facts', () => { const image = packed().image // RFC 1952 §2.3: magic, deflate, then the flag byte — no FNAME (0x08) or diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index cd8dbcb7dc..e03fb6e9c9 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -167,6 +167,9 @@ export class WorkerTunnel { /** * `loadBundle` seam: take one client bundle through the tunnel and execute it * as a classic script, exactly like the shell's same-origin `') - const runtimeAt = html.indexOf('') const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') - expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([ - facadeAt, modulesAt, runtimeAt, graphAt, entryAt, + expect(html).not.toContain('') + expect([facadeAt, modulesAt, graphAt, entryAt]).toEqual([...new Set([ + facadeAt, modulesAt, graphAt, entryAt, ])].sort((a, b) => a - b)) target.load({ id: MODULES_ID, factory: () => modulesClient }) - target.load({ id: RUNTIME_ID, factory: () => ({ marker: 'runtime' }) }) + target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) }) const system = target.create({ boot: graph, staticModules: {} }) expect(target.mode).toBe('live') expect(target.pendingQueue).toEqual([]) expect(system.manifest.rev).toBe('graph') expect(await system.import(MODULES_ID)).toBe(modulesClient) - expect(await system.import(`${RUNTIME_ID}/client`)).toEqual({ marker: 'runtime' }) + expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' }) expect(() => target.create({ boot: graph, staticModules: {} })) .toThrow('create called after module-system boot') }) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml deleted file mode 100644 index c2c2e22d00..0000000000 --- a/packages/client/runtime/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: adeeca51ef2948da09c2906c803adcd49dcc4b74 -README.zh.md: 8235593bf00a6634efa7c98a1b3c3bea9eafa564 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md deleted file mode 100644 index adeeca51ef..0000000000 --- a/packages/client/runtime/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# @deepseek-ai/dsh-client-runtime - -English | [中文](README.zh.md) - -Client cordis boot and React-free object services: SlotRegistry wraps SlotCore and supplies renderer data sources; SessionRuntime owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. Each durable window uses one Session Controller journal stream; one Host-wide snapshot stream supplies queue, jobs, projections, approvals, and questions. WorkspaceRuntime depends on SessionRuntime and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`); Workspace Controller supplies its reconnecting snapshot stream. Domain packages subscribe to forwarded Host events through `ctx.remote.$on`. - -Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state. A session's Agent scope, the client mirror of Host dsh-scope keyed by the shared Agent/Session id, is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from Session list or page projection blocks and updated by control-stream projection replacements under higher-seq-wins. Domain keys, including `todos`, are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. - -For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state. - -Settings owners share the React-free `SettingsScopeSpec`, `SettingsScope`, and snapshot types defined here. ui-settings owns `ctx.settingsScope.bind(spec)`, its Host transport, schema validation, and lifecycle; see [its package contract](../ui-settings/README.md). - -## Slot declaration injection - -`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. - -The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several `slots.register()` calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the [declaration-injection decision](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md). - -## Workspace and Session lists - -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspaceRuntime.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. - -`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks control-stream requested/resolved frames by stable `interactionId` even before a Session object is instantiated; pre-instantiation state retains every live request, replaces duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. Every control generation begins with a complete baseline that replaces the pending set and therefore restores only requests that remain answerable. - -`WorkspaceRuntime.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. - -`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspaceRuntime.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. - -SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; ui-renderer creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. - -`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged. - -`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. - -`SessionListState.jobsBySession` mirrors the Session Controller control stream, keyed by Session and needing no Session instance. Each control baseline replaces the complete map; later `jobs` frames are last-wins replacements for one Session. An empty set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. The forwarded `api-session/removed` event also clears that Session's jobs. - -`SessionRuntime.search(query, signal)` is a stateless one-shot action over `ctx.remote.session.search`. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. - -## New Session and the blank mirror - -`WorkspaceRuntime.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the Host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `ctx.remote.session.create({workspaceId})`, returning the Session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the Host's derived empty-log bit and only ever lowers on the client: it is seeded by `session.list` or `api-session/added`, flips false after the first accepted local `prompt()` and on any `api-session/status` event with `running: true`, and is re-aligned by every list pull. List surfaces hide blank rows; the store carries every row. `SessionRuntime.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` carrying `requestedSessionId` on failure. - -`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it. - -## Pending queue projection - -`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of both `agent.inbox.nextTurn` and `nextStep`. Rows are tagged `queued`, `steering`, or `context`; each carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. Every control generation starts with complete queue snapshots, and later `queue` replacements follow `agent/inbox/spliced` changes; message-local inserted, claimed, and discarded notifications are not used to reconstruct the projection. `Session.updateQueue()` sends mutations without optimistic client state, so the next Host snapshot is the visible commit and a claim race can surface `queue-item-not-found`. - -## Conversation assembly - -Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. - -Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path. - -`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. `ui-trajectory` registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold. - -The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). - -## Trajectory request data - -Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized `assistant/message` retains its durable result seq and provider provenance but does not complete the request; `step/end` classifies that request as an error. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. - -## Code Mode child-call tree - -Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target. - -## Session title projection - -`SessionManager` retains the generic `title` projection independently of list and Session-instance arrival. Session list summaries, page tails, and control frames all seed the same store; higher event seqs replace lower ones, and a replacement control baseline truncates rows beyond its watermark before seeding its values. Explicit Session removal clears the store. List `updatedAt` is Host-owned and derives from the latest human prompt, so a title change does not affect recency. The client-facing `SessionSummary.title` is only the durable title; `displayTitle` is always present and falls back through the cwd basename and Session id. `ISession.rename` settles the `title` projection cell directly from the Remote response's `{title, seq}` under the same higher-seq-wins rule, so list and `useProjection('title')` readers update before a later replay of the same seq. - -## Model retry projection - -The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed attempt's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error projects one `turn-error` node from its durable message and optional code — after exhausted retries it renders beside the settled retry notice; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. An intermediate failure that scheduled another retry keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error. - -A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh and restore. - -## Session forking - -`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. - -## Model selection ownership - -Session Runtime carries no model-selection snapshot. `ui-model-selection` owns one scoped `ModelDirectory` per Session and calls `ctx.remote.session.models` and `selectModel` directly; Runtime supplies only Session scope and address information. That package resets its directory on `connection/reset`, shares one latest-generation-wins store between its two selectors, and disposes it with the Session scope. - -## Model Experience - -None, as this package adds no model-visible content; model selection belongs to `ui-model-selection` and the Host Session Controller. - -#### KV Cache effect - -None directly; this package neither selects a model nor alters the prompt prefix. - -## Known Limitations and Deferred Work - -- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal. -- **Scope teardown is stage-driven and single-occupant** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land. -- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md deleted file mode 100644 index 8235593bf0..0000000000 --- a/packages/client/runtime/README.zh.md +++ /dev/null @@ -1,99 +0,0 @@ -# @deepseek-ai/dsh-client-runtime - -[English](README.md) | 中文 - -客户端 cordis 启动与不依赖 React 的对象服务:SlotRegistry 包装 SlotCore 并提供 renderer 数据源;SessionRuntime 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。每个持久窗口使用一条 Session Controller journal stream;一条 Host 级 snapshot stream 提供 queue、jobs、projection、approval 与 question。WorkspaceRuntime 依赖 SessionRuntime,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`);Workspace Controller 提供它的可重连 snapshot stream。各领域包通过 `ctx.remote.$on` 订阅 Host 转发事件。 - -客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、Agent 和 cwd);客户端不持有任何实体化之前的会话状态。Agent scope 是 Host dsh-scope 的客户端镜像,以 Agent/Session 共用 id 为键,在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由 Session 列表或 page 的 projection block 播种,并经 control 流的 projection replacement 按 seq 高者胜更新。领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 - -对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存,也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。 - -设置所有者共用本包定义的不依赖 React 的 `SettingsScopeSpec`、`SettingsScope` 与快照类型。ui-settings 拥有 `ctx.settingsScope.bind(spec)`、对应的 Host 传输、schema 校验与生命周期;详见[该包的约定](../ui-settings/README.zh.md)。 - - - -## Slot 声明注入 - -`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 - -回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此,generator 可以 yield 多个 `slots.register()` 调用,并将它们组成一项事务:setup 失败会回滚先前 yield 的 effect,teardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch(声明代次),因此,即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md)。 - -## Workspace 与 Session 列表 - -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspaceRuntime.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 - -`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的 `interactionId` 跟踪 control 流的 requested/resolved 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的状态会保留每个仍有效的请求、替换重复项并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。每一代 control 都以完整 baseline 开始并替换 pending 集合,因此只恢复仍可应答的请求。 - -`WorkspaceRuntime.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 - -`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspaceRuntime.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 - -SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;ui-renderer 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。 - -`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。 - -`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 - -`SessionListState.jobsBySession` 镜像 Session Controller control 流,以 Session 为键,不需要 Session 实例。每份 control baseline 替换完整映射;后续 `jobs` 帧按 last-wins 替换单个 Session。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。转发的 `api-session/removed` 事件也会清除该 Session 的 jobs。 - -`SessionRuntime.search(query, signal)` 是基于 `ctx.remote.session.search` 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 - -## New Session 与 blank 镜像 - -`WorkspaceRuntime.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 Workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——Host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `ctx.remote.session.create({workspaceId})`,返回 Session id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像 Host 派生的空日志位,在客户端只降不升:由 `session.list` 或 `api-session/added` 播种,本地首次获 Host 接受的 `prompt()` 后与任何 `running: true` 的 `api-session/status` 事件都会将其翻为 false,每次列表拉取重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionRuntime.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出携带 `requestedSessionId` 的 `SessionCreateError`。 - -`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。 - -## 待处理队列投影 - -`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 与 `nextStep` 权威瞬态快照。各行标记为 `queued`、`steering` 或 `context`,并携带 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。每一代 control 都以完整 queue 快照开始,后续 `queue` replacement 跟随 `agent/inbox/spliced` 变更;面向单条消息的 inserted、claimed 与 discarded 通知不用于重建该投影。`Session.updateQueue()` 不做乐观客户端变更,下一份 Host 快照才是可见提交结果,claim 竞态则可能呈现 `queue-item-not-found`。 - -## Conversation 组装 - -每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 - -Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.zh.md)。 - -`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。`ui-trajectory` 在同一个 Session 窗口上注册独立 Definition 与 target builder;它保留现有的 stage-oriented view model,既不消费 Chat 兼容字段,也不运行另一套 history fold。 - -Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md)。 - -## Trajectory 请求数据 - -Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。由取消定稿的 `assistant/message` 会保留持久结果 seq 和提供方信息,但不会将请求标记为完成;`step/end` 会把该请求归类为错误。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 - -## Code Mode 子调用树 - -每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。Trajectory 的 Tool Definition 为自己的 target 独立组装同一种嵌套数据契约。 - -## Session 标题投影 - -`SessionManager` 独立于列表和 Session 实例到达情况,保留通用的 `title` projection。Session 列表摘要、page 尾部与 control 帧都会播种同一个 store;seq 更高的事件替换较低值,而替换 control baseline 会先截断超过其 watermark 的行,再播种自己的值。显式移除 Session 也会清除该 store。列表 `updatedAt` 由 Host 所有并根据最近一次真人 prompt 派生,因此标题变化不影响新近程度。面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。`ISession.rename` 用 Remote 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则,因此列表行和所有 `useProjection('title')` 读者会在后续同 seq 回放前更新。 - -## 模型重试投影 - -Host 所属的 LLM(大语言模型)retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry` 与 `llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败尝试的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`,started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点——重试耗尽后它与定格的重试提示并列渲染;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。安排了下一次重试的中间失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。 - -reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,刷新和恢复后结束原因保持一致。 - -## 会话 fork - -`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端根据源会话的持久化标题重命名子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 - -## 模型选择所有权 - -Session Runtime 不携带模型选择快照。`ui-model-selection` 为每个 Session 拥有一个 scope 绑定的 `ModelDirectory`,并直接调用 `ctx.remote.session.models` 与 `selectModel`;Runtime 只提供 Session scope 和地址信息。该包在 `connection/reset` 时重置目录,让两个 selector 共用一份 latest-generation-wins store,并随 Session scope 销毁它。 - -## 模型体验 - -无,因为本包不添加模型可见内容;模型选择由 `ui-model-selection` 与 Host Session Controller 所有。 - -#### KV Cache 影响 - -无直接影响;本包既不选择模型,也不改变提示词前缀。 - -## 已知限制与暂缓事项 - -- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链。 -- **scope 拆卸由阶段驱动且只有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 -- **插件 bundle 从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json deleted file mode 100644 index f9df4d14d2..0000000000 --- a/packages/client/runtime/package.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-runtime", - "description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)", - "version": "0.1.1-rc.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/runtime" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "dsh": { - "client": { - "external": [ - "@deepseek-ai/dsh-api-gateway/client", - "@deepseek-ai/dsh-api-session-controller/client", - "@deepseek-ai/dsh-api-workspace-controller/client" - ], - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-typert-registry", - "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-api-session-controller", - "@deepseek-ai/dsh-api-workspace-controller" - ], - "platform": "web", - "immediately": true - } - }, - "license": "MIT", - "dependencies": { - "immer": "^10.1.1", - "zustand": "~4.4.7" - }, - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "react": "^18.2.0", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.d.ts" - ] -} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts deleted file mode 100644 index 593e076a96..0000000000 --- a/packages/client/runtime/src/client/index.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ -import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { - createSessionControlStream, - SESSION_SEARCH_RESULT_LIMIT, -} from '@deepseek-ai/dsh-api-session-controller/client' -import { - createWorkspaceStateStream, ClientWorkspaceModel, -} from '@deepseek-ai/dsh-api-workspace-controller/client' -// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather -// than api-remotes': that face imports a Host-tsdown-generated artifact, and this -// project sits in the Host build graph. -import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { TypertContext } from '@deepseek-ai/dsh-typert-protocol' -import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from './slots.ts' -import { SessionRuntime } from './sessions/service.ts' -import type { SessionListState } from './sessions/service.ts' -import { WorkspaceRuntime } from './workspaces/service.ts' -import type { ConversationSnapshot } from './sessions/conversation.ts' -import type { UseProjection } from './sessions/projection-store.ts' -import { ConversationEventRegistry } from './conversation/event-registry.ts' -import { ConversationViewRegistry } from './conversation/view-registry.ts' - -export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -export { SESSION_SEARCH_RESULT_LIMIT } - -export { SlotRegistry } from './slots.ts' -export { ConversationEventRegistry } from './conversation/event-registry.ts' -export { ConversationViewRegistry } from './conversation/view-registry.ts' -export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts' -export { ConversationLocationIndex } from './sessions/conversation-location-index.ts' -export { conversationContextKey } from './contract/conversation.ts' -export type { - ChatConversationViewNode, ConversationContextReader, ConversationEventInput, - ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore, - ConversationStepDataMap, - ConversationLocation, ConversationMatch, ConversationMatchResult, - ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, - ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder, - ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap, - ConversationViewSnapshotStore, StepLocation, TurnLocation, -} from './contract/conversation.ts' -export type { ConversationRuntime } from './sessions/conversation-assembler.ts' -export type { RootOwnerProps } from './slots.ts' -export { SessionCreateError, SessionRuntime, scopeOf, workspaceTitleOf } from './sessions/service.ts' -export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' -export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' -export { SessionProvideChannel } from './sessions/provide.ts' -export type { SessionProvideChannelHost } from './sessions/provide.ts' -export { createScope } from './agents/scope.ts' -export type { AgentScopeHandle } from './agents/scope.ts' -export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts' -export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts' -// Contract only: the scope implementation and its Host transport belong to -// dsh-client-ui-settings (see that package's settings-scope.ts). -export type { - SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec, -} from './contract/settings-scope.ts' -export type { Session } from './sessions/session.ts' -export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' -export type { AgentContext, ISessions } from './contract/sessions.ts' -export type { IWorkspaces } from './contract/workspaces.ts' -export type { - SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, -} from './sessions/service.ts' -export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts' -export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' -export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' -export type { WorkspaceListPhase } from '@deepseek-ai/dsh-api-workspace-controller/client' -export type { WorkspaceListState } from './workspaces/service.ts' -export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' -export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' -// Runtime owns the snapshot store; ui-renderer only binds it to React. -export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' -export type { - EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, -} from './contract/store.ts' -export type { - AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, - AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, - CommandNode, CompactionSummaryNode, ComposerPhase, - ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - LegacyConversationSlice, PartialAssistant, RunningToolCall, - SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, - UnknownSurfaceNode, UserMessageNode, -} from './sessions/conversation.ts' -export { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks, -} from './sessions/conversation.ts' -export { emptyAssistantBlock } from './sessions/partial.ts' -export { isTokenDelta } from './sessions/assistant-timing.ts' -export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' -export { displayFailureMessage } from './sessions/failure-display.ts' -export type { - ConversationContext, ConversationContextOriginKind, -} from './sessions/conversation-context.ts' -export type { - ContextProvenanceView, ContextRole, KnownContextForm, -} from './sessions/context-provenance.ts' -export type { - ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, -} from './sessions/request-inspection.ts' -export { PendingWait } from './sessions/pending.ts' -export type { - PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads, - PendingQuestionAnswer, PendingQuestionItem, PendingQuestionOption, PendingRespondReceipt, -} from './sessions/pending.ts' -// Projection value store (push model; see the session-projection subsystem -// page, docs/subsystems/session-projection.md): host-computed -// whole values per key; domains ship projection support with zero client code. -export type { - ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection, -} from './sessions/projection-store.ts' -export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' - -/** Client-side Cordis context after declaration merging. */ -export type ClientContext = Context - -declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertContextMap { - /** Client Agent scope identity; the agent and session share one wire id. */ - agent: TypertContext - } -} - -/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */ -export type UseConversationSession = SnapshotSelectorHook - -declare module '@deepseek-ai/dsh-client-ui-slots' { - /** - * Session standard kit, real members (ui-slots declares the empty seat; - * the runtime — where the subjects live — merges the concrete types): - * every session-scope slot component receives these from the framework. - */ - interface SessionStandardProps { - useSession: SnapshotSelectorHook - /** The framework-resolved session id (owners never pass it). */ - sessionId: SessionId - /** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */ - useProjection: UseProjection - } - /** Standard kit for slots that remain mounted while current session changes. */ - interface SessionMaybeStandardProps { - useSession: MaybeSnapshotSelectorHook - /** Current session id; absent in the no-session state. */ - sessionId: SessionId | undefined - /** Key-addressed projection reader; every key reads absent while no session is current. */ - useProjection: UseProjection - } - /** Props injected into every global slot component. */ - interface GlobalStandardProps { - useSessions: SnapshotSelectorHook - /** Selector hook over real Workspaces and their independent baseline lifecycle. */ - useWorkspaces: SnapshotSelectorHook - } -} - -declare module '@deepseek-ai/cordis' { - interface Events { - /** - * A slot's definition or registration set changed. - * @mode emit - * @param key - the mutated SlotMap key. - */ - 'slots/changed'(key: string): void - /** - * A connection generation was (re-)established. Wire-derived caches must - * treat their state as stale and repull. Session follow and control - * streams own their independent resume and baseline lifecycles. - * @mode emit - */ - 'connection/reset'(): void - } - interface Context { - slots: import('./slots.ts').SlotRegistry - /** Event-to-business-Context Definition registry. */ - conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry - /** Per-target Conversation snapshot builder registry. */ - conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry - /** The outward face only; the concrete service stays inside the runtime. */ - sessions: import('./contract/sessions.ts').ISessions - /** The outward face only; the concrete service stays inside the runtime. */ - workspaces: import('./contract/workspaces.ts').IWorkspaces - } -} - -/** Required services: the wire handle and Client Typert registry. */ -export const inject = [ - 'connection', - 'typert', - 'remote', - 'remote.commands', - 'remote.session', - 'remote.workspace', -] - -/** Mounts the browser runtime services and connection stream. - * @param ctx - Client Cordis context. - */ -export function apply(ctx: Context): void { - ctx.plugin(SlotRegistry) - const conversation = { - events: new ConversationEventRegistry(ctx), - views: new ConversationViewRegistry(ctx), - } - const connection = ctx.get('connection') as ConnectionHandle - const sessions = new SessionRuntime(ctx, connection.api, ctx.remote, conversation) - ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) - ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) - ctx.remote.$on('api-session/status', (sessionId, running) => { - sessions.handleSessionStatus(sessionId, running) - }) - ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => { - sessions.handleSessionActivity(sessionId, updatedAt) - }) - ctx.remote.$on('api-session/error', (sessionId, message) => { - sessions.handleSessionError(sessionId, message) - }) - const sessionControl = createSessionControlStream(ctx.remote, { - accept: (frame) => { sessions.handleControlFrame(frame) }, - failed: (error) => { console.error('[web-runtime] session control stream failed:', error) }, - }) - sessionControl.start() - ctx.typert.contexts.registerClient('agent', { - identity: candidate => sessions.scopeOf(candidate), - resolve: sessionId => sessions.resolveAgentScope(sessionId), - }) - const workspaceModel = new ClientWorkspaceModel(ctx.remote.workspace) - const workspaces = new WorkspaceRuntime(ctx, connection.api, workspaceModel, sessions) - const workspaceControl = createWorkspaceStateStream(ctx.remote, { - accept: workspaceModel, - carrierFailed: () => { workspaceModel.handleCarrierFailure() }, - failed: (error) => { workspaceModel.handleStreamFailure(error) }, - }) - workspaceControl.start() - ctx.effect( - () => workspaces.startInitialSelection(), - 'runtime: initial Workspace selection', - ) - ctx.on('connection/reset', () => { sessions.handleConnected() }) - if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected() - ctx.effect(() => async () => { - await Promise.all([ - workspaceControl.dispose(), - sessionControl.dispose(), - ]) - }, 'runtime: connection streams') -} diff --git a/packages/client/runtime/src/index.ts b/packages/client/runtime/src/index.ts deleted file mode 100644 index c1ea85d1e5..0000000000 --- a/packages/client/runtime/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** 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/src/invariant.ts b/packages/client/runtime/src/invariant.ts deleted file mode 100644 index c11a014cd9..0000000000 --- a/packages/client/runtime/src/invariant.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-runtime`. - * @module @deepseek-ai/dsh-client-runtime/invariant - */ - -/* jscpd:ignore-start */ -/* oxlint-disable typescript/no-redundant-type-constituents -- - * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty - * in this compilation unit (intersection reads `never`) but consumers merge - * keys in; the rule fires on the empty-map view, not on real redundancy. */ -import type { Context } from '@deepseek-ai/cordis' -import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-runtime' - -/** Cordis companion plugin name. */ -export const name = 'client-runtime-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * Owned relation: every 'slots/changed'(key) emission must observe the - * mutation already applied — SlotCore bumps the key's version synchronously - * before the service re-emits, so a zero version at dispatch time means the - * event fired without (or ahead of) its mutation. - */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.on('internal/dispatch', (_mode, eventName, args) => { - if (eventName !== 'slots/changed') return - const key: unknown = args[0] - if (typeof key !== 'string' || key === '') { - fail("'slots/changed' dispatched without a slot key argument") - return - } - const slots = ctx.get('slots') - // Event payloads carry keys as plain strings; getVersion is statically - // keyed, so restore the SlotMap-key type after the runtime string check. - if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) { - fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`) - } - }, { global: true }) -} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/runtime/tests/node-half.client.spec.ts b/packages/client/runtime/tests/node-half.client.spec.ts deleted file mode 100644 index 8a680eb036..0000000000 --- a/packages/client/runtime/tests/node-half.client.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */ -import { describe, expect, it } from 'vitest' -import { apply } from '../src/index.ts' - -describe('node half', () => { - it('apply is a no-op host placeholder', () => { - apply(undefined) - expect(true).toBe(true) // reaching here without throw is the contract - }) -}) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json deleted file mode 100644 index 252b0d0499..0000000000 --- a/packages/client/runtime/tsconfig.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../attachment/attachment" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../ui-slots" - }, - { - "path": "../../host/apiproxy" - }, - { - "path": "../../interaction/commands" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../compaction/compaction" - }, - { - "path": "../../session/session-projection" - }, - { - "path": "../../session/session-title" - }, - { - "path": "../../todo/tool-todo" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../llm/llm-retry" - }, - { - "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../typert/protocol" - }, - { - "path": "../../typert/registry" - }, - { - "path": "../../api/remotes/tsconfig.client.json" - }, - { - "path": "../../api/session-controller/tsconfig.client.json" - }, - { - "path": "../../api/workspace-controller/tsconfig.client.json" - }, - { - "path": "../../util/crypto" - } - ], - "exclude": [ - "**/*.legacy.*" - ] -} diff --git a/packages/client/runtime/tsdown.config.ts b/packages/client/runtime/tsdown.config.ts deleted file mode 100644 index 11118f7f4e..0000000000 --- a/packages/client/runtime/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 3e98310cca..06448f27a1 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -9,7 +9,7 @@ */ import { useEffect } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx index eab363122c..d2338596ba 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSettingsState } from './settings-store.ts' import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index f7350076c2..cf71faf9d7 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -13,7 +13,7 @@ */ import { useEffect, useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index ed7f9c24b8..59ccb19226 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -15,7 +15,7 @@ import type { ReactNode } from 'react' import { Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index c21aed8bd8..d40b14ae00 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -22,8 +22,8 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -// Type-only: pulls the Session UI navigation service merge (ctx.uiSession). -import type {} from '@deepseek-ai/dsh-client-ui-session/client' +// Type-only: pulls the Workspace UI navigation service merge (ctx.uiWorkspace). +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { AgentPresetLabel } from './AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx' @@ -104,7 +104,7 @@ export function apply(ctx: ClientContext): void { // The new-session chip and the header label: one controller, because the // staged choice belongs to the flow rather than to any one session. - ctx.inject(['slots', 'conversation', 'sessions', 'uiSession'], (scope: ClientContext) => { + ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { const api = (scope.get('connection') as ConnectionHandle).api const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => { const state = scope.sessions.list.getSnapshot() @@ -165,7 +165,7 @@ export function apply(ctx: ClientContext): void { // The introduce cue makes the chip announce the pick the user never // made on this screen — the stage happened back in settings. seat.stage('cordis', true) - scope.uiSession.startSession() + scope.uiWorkspace.startSession() } const chip = scope.slots.register({ name: 'conversation.hero.agentPreset', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index c91e77bbd5..64e71fefb7 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -11,9 +11,8 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { - createSnapshotStore, type SessionId, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { messageOf, presetOptions } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 0499d24a3b..a4af289990 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -15,7 +15,7 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' /** Ids a preset directory may be named, mirroring the host's own rule. */ diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index cf770151bd..d0a89f358b 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -8,7 +8,7 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 7c3e30a96b..e93570355e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -143,8 +143,8 @@ function declareConversation(slots: SlotRegistry): () => void { } as never, () => null) } -/** A Session UI double recording new-session starts. */ -function uiSessionDouble() { +/** A Workspace UI double recording new-session starts. */ +function uiWorkspaceDouble() { const starts: unknown[] = [] return { starts, @@ -306,8 +306,8 @@ describe('ui-agent-preset apply', () => { const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('uiSession', uiSessionDouble() as never) - const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }) + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }) await fiber.await() const chip = slots.entries('conversation.hero.agentPreset')[0]! @@ -328,8 +328,8 @@ describe('ui-agent-preset apply', () => { const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = slots.entries('conversation.hero.agentPreset')[0]! const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() @@ -364,8 +364,8 @@ describe('ui-agent-preset apply', () => { byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, } ctx.provide('sessions', sessionsDouble(state) as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() remote.emit('agent-preset/selected', ['s1', 'minimal']) @@ -378,8 +378,8 @@ describe('ui-agent-preset apply', () => { const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = slots.entries('conversation.hero.agentPreset')[0]! const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() @@ -413,8 +413,8 @@ describe('ui-agent-preset apply', () => { } = { byId: {} } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -441,8 +441,8 @@ describe('ui-agent-preset apply', () => { byId: { s1: { id: 's1', blank: true } }, }) ctx.provide('sessions', sessions as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -465,8 +465,8 @@ describe('ui-agent-preset apply', () => { } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -488,8 +488,8 @@ describe('ui-agent-preset apply', () => { declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const label = (slots.entries('conversation.session.header.actions')[0]! .inject as unknown as () => AgentPresetLabelInjected)() const row = (slots.entries('settings.general.item')[0]! @@ -509,9 +509,9 @@ describe('ui-agent-preset apply', () => { const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - const uiSession = uiSessionDouble() - ctx.provide('uiSession', uiSession as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + const uiWorkspace = uiWorkspaceDouble() + ctx.provide('uiWorkspace', uiWorkspace as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() const seat = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -523,7 +523,7 @@ describe('ui-agent-preset apply', () => { // new-session flow began. expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiSession.starts).toHaveLength(1) + expect(uiWorkspace.starts).toHaveLength(1) // A cross-screen stage carries the introduce cue; the chip acknowledges // it once, and a repeat acknowledgement leaves the snapshot untouched. @@ -547,8 +547,8 @@ describe('ui-agent-preset apply', () => { } = { byId: {} } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) - ctx.provide('uiSession', uiSessionDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() const seat = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index b7fc7c2dec..29ecc33eb5 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -10,7 +10,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx' import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 3fdea20bfe..7a829df10b 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -9,7 +9,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts' diff --git a/packages/client/ui-brand-official/src/client/index.ts b/packages/client/ui-brand-official/src/client/index.ts index b291bda3e7..237272bd1d 100644 --- a/packages/client/ui-brand-official/src/client/index.ts +++ b/packages/client/ui-brand-official/src/client/index.ts @@ -1,6 +1,7 @@ /** Official DeepSeek Harness occupants for the generic browser-brand slots. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx' diff --git a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx index ee5275b9b7..8fa5ca8056 100644 --- a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply, inject } from '../src/client/index.ts' import { OfficialBrandMark, OfficialBrandName } from '../src/client/Brand.tsx' diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 7903fb44e1..4f4374735b 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -3,7 +3,7 @@ * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages * consume `register` alone. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client' /** Copy for an option that must be acknowledged before onSelect can run. */ diff --git a/packages/client/ui-commands/src/client/directory.ts b/packages/client/ui-commands/src/client/directory.ts index 3e6a9cf2eb..04d5eebb3b 100644 --- a/packages/client/ui-commands/src/client/directory.ts +++ b/packages/client/ui-commands/src/client/directory.ts @@ -6,7 +6,7 @@ * is the only extra dimension. */ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index bc8e2ca7fc..26e8a32e09 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -5,13 +5,16 @@ * popupSelect shell self-registers into conversation.input.overlay with * per-session resolution. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the 'conversation.input.overlay' SlotMap declaration (the // key's owner) into this program so the overlay registration below typechecks // against the real declaration — no runtime edge to ui-conversation. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { CommandUiRuntime } from './service.ts' import type { PopupSelectInjected } from './PopupSelectView.tsx' import { PopupSelectView } from './PopupSelectView.tsx' @@ -57,7 +60,7 @@ export function apply(ctx: ClientContext): void { ctx.plugin(CommandUiRuntime) ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => { const command = scope.commandUi - const sessions = scope.sessions + const sessions = scope.get('sessions') as ISessions scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'command-popup', diff --git a/packages/client/ui-commands/src/client/popup.ts b/packages/client/ui-commands/src/client/popup.ts index e9d2e05a6b..f7e7b186be 100644 --- a/packages/client/ui-commands/src/client/popup.ts +++ b/packages/client/ui-commands/src/client/popup.ts @@ -9,8 +9,8 @@ * Input side owns the span/bare-token CAS guard) and focuses the composer; * the controller never touches the input machine. */ -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { SelectOption } from './contract.ts' diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 5db2033e29..3ae9b5d324 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -13,7 +13,9 @@ import type { Context } from '@deepseek-ai/cordis' // (`commands/change` rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' -import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, diff --git a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts index 3b243b25f2..c2143a35c3 100644 --- a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts @@ -8,8 +8,9 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandUiContract } from '../src/client/contract.ts' import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx' diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index afaabef7f5..ffd2946b80 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -10,8 +10,8 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' -import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index cfd335d59b..a570478a45 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -42,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the Conversation plugin. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'locale', 'settingsScope', + 'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', ] // Stable no-session sources keep the renderer's observable-hook cache and @@ -65,6 +65,12 @@ const ABSENT_MENU_LAUNCHER = { subscribe: () => () => {}, } +interface WorkspaceNavigation { + connectWorkspace( + workspaceId: Parameters[0], + ): Promise +} + /** Resolve the session-scoped Conversation action face, failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { const scoped = sessions.scope(id) @@ -90,6 +96,7 @@ function concreteConversation(ctx: Context): ConversationController { export function apply(ctx: Context): void { const sessions = ctx.sessions const slots = ctx.slots + const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') @@ -184,7 +191,7 @@ export function apply(ctx: Context): void { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), }, selectWorkspace: async (workspaceId) => { - const nextId = await ctx.uiSession.connectWorkspace(workspaceId) + const nextId = await workspaceNavigation.connectWorkspace(workspaceId) if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft diff --git a/packages/client/ui-conversation/src/client/pending-interactions.ts b/packages/client/ui-conversation/src/client/pending-interactions.ts deleted file mode 100644 index 5f2b5bda7f..0000000000 --- a/packages/client/ui-conversation/src/client/pending-interactions.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** Presentation-only pending interactions received through Remote Events. */ -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { - createSnapshotStore, - type ObservableSnapshot, - type PendingInteraction, - type PendingInteractionStatus, -} from '@deepseek-ai/dsh-client-runtime/client' - -const EMPTY_INTERACTIONS: readonly PendingInteraction[] = [] -const ABSENT_INTERACTIONS: ObservableSnapshot = { - getSnapshot: () => EMPTY_INTERACTIONS, - subscribe: () => () => {}, -} - -interface PendingEntry { - readonly interaction: PendingInteraction - readonly status: PendingInteractionStatus - readonly precedence: number -} - -interface PendingPresentationSnapshot { - readonly interactions: ReadonlyMap - readonly statuses: ReadonlyMap -} - -/** Presentation sources shared by the composer and Session navigation. */ -export interface PendingInteractionPresentation { - /** Effective pending-interaction status by Session. */ - readonly statuses: ObservableSnapshot> - /** - * Resolve the effective composer interaction for one Session. - * @param sessionId - current Session identity, or absence outside a Session scope. - * @returns an identity-stable observable source. - */ - forSession(sessionId: SessionId | undefined): ObservableSnapshot - /** - * Publish one domain-owned interaction until its disposer runs. - * @param interaction - answerable presentation object. - * @param status - sidebar presentation kind. - * @param precedence - deterministic cross-domain priority; larger values win. - * @returns idempotent removal function. - */ - present( - interaction: PendingInteraction, - status: PendingInteractionStatus, - precedence: number, - ): () => void -} - -/** Aggregate domain-owned Remote Event waits without putting them on Session state. */ -export class PendingInteractionPresenter implements PendingInteractionPresentation { - private readonly entries = new Map() - private readonly sources = new Map>() - private readonly state = createSnapshotStore({ - interactions: new Map(), - statuses: new Map(), - }) - - /** Effective pending-interaction status by Session. */ - readonly statuses: ObservableSnapshot> = { - getSnapshot: () => this.state.getSnapshot().statuses, - subscribe: listener => this.state.subscribe(listener), - } - - /** @inheritdoc */ - forSession(sessionId: SessionId | undefined): ObservableSnapshot { - if (sessionId === undefined) return ABSENT_INTERACTIONS - let source = this.sources.get(sessionId) - if (source === undefined) { - source = { - getSnapshot: () => this.state.getSnapshot().interactions.get(sessionId) ?? EMPTY_INTERACTIONS, - subscribe: listener => this.state.subscribe(listener), - } - this.sources.set(sessionId, source) - } - return source - } - - /** @inheritdoc */ - present( - interaction: PendingInteraction, - status: PendingInteractionStatus, - precedence: number, - ): () => void { - if (this.entries.has(interaction.key)) { - throw new Error(`ui-conversation: duplicate pending interaction key '${interaction.key}'`) - } - const entry = { interaction, status, precedence } - this.entries.set(interaction.key, entry) - this.publish() - let active = true - return () => { - if (!active) return - active = false - interaction.markSettled() - this.entries.delete(interaction.key) - this.publish() - } - } - - private publish(): void { - const selected = new Map() - for (const entry of this.entries.values()) { - const previous = selected.get(entry.interaction.sessionId) - if (previous === undefined || entry.precedence >= previous.precedence) { - selected.set(entry.interaction.sessionId, entry) - } - } - this.state.set({ - interactions: new Map( - [...selected].map(([sessionId, entry]) => [sessionId, [entry.interaction]] as const), - ), - statuses: new Map( - [...selected].map(([sessionId, entry]) => [sessionId, entry.status] as const), - ), - }) - } -} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 490f28a835..db5bf29baa 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -33,7 +33,8 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - const connectWorkspace = vi.spyOn(runtime.ctx.uiSession, 'connectWorkspace').mockResolvedValue(ROOT) + const connectWorkspace = vi.fn(async () => ROOT) + runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index 5d7da62bd6..168d0e6230 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -14,6 +14,7 @@ const SID = 'session-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index 15b38417c2..692a53817b 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -50,6 +50,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -71,6 +72,7 @@ async function bench(opts?: { blank?: boolean }) { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -97,6 +99,7 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -161,6 +164,7 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/pending-interactions.client.spec.ts b/packages/client/ui-conversation/tests/pending-interactions.client.spec.ts deleted file mode 100644 index eba1236739..0000000000 --- a/packages/client/ui-conversation/tests/pending-interactions.client.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import { PendingInteractionPresenter } from '../src/client/pending-interactions.ts' - -const sid = (value: string) => value as SessionId - -function approval(id: string, sessionId = sid('session')): PendingWait<'approval'> { - return new PendingWait( - 'approval', id, sessionId, { approvalId: id, toolName: 'bash' }, - () => Promise.resolve({ ok: true, value: { accepted: true } }), - ) -} - -function question(id: string, sessionId = sid('session')): PendingWait<'question'> { - return new PendingWait( - 'question', id, sessionId, { questions: [{ id: 'choice', question: 'Choose?' }] }, - () => Promise.resolve({ ok: true, value: { accepted: true } }), - ) -} - -describe('PendingInteractionPresenter', () => { - it('publishes one effective interaction and status per Session by precedence', () => { - const presenter = new PendingInteractionPresenter() - const source = presenter.forSession(sid('session')) - const notifyInteraction = vi.fn() - const notifyStatuses = vi.fn() - source.subscribe(notifyInteraction) - presenter.statuses.subscribe(notifyStatuses) - const approvalWait = approval('approval') - const questionWait = question('question') - - const removeApproval = presenter.present(approvalWait, 'approval', 0) - expect(source.getSnapshot()).toEqual([approvalWait]) - expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('approval') - - const removeQuestion = presenter.present(questionWait, 'question', 1) - expect(source.getSnapshot()).toEqual([questionWait]) - expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('question') - - removeQuestion() - expect(source.getSnapshot()).toEqual([approvalWait]) - removeApproval() - expect(source.getSnapshot()).toEqual([]) - expect(presenter.statuses.getSnapshot()).toEqual(new Map()) - expect(notifyInteraction).toHaveBeenCalledTimes(4) - expect(notifyStatuses).toHaveBeenCalledTimes(4) - }) - - it('uses publication order to replace an equal-precedence interaction', () => { - const presenter = new PendingInteractionPresenter() - const source = presenter.forSession(sid('session')) - const first = question('first') - const second = question('second') - const removeFirst = presenter.present(first, 'question', 1) - const removeSecond = presenter.present(second, 'plan-review', 1) - - expect(source.getSnapshot()).toEqual([second]) - expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('plan-review') - removeSecond() - expect(source.getSnapshot()).toEqual([first]) - removeFirst() - }) - - it('isolates Sessions, rejects duplicate keys, and removes idempotently', () => { - const presenter = new PendingInteractionPresenter() - const first = approval('same', sid('first')) - const secondSession = approval('second', sid('second')) - const removeFirst = presenter.present(first, 'approval', 0) - const removeSecond = presenter.present(secondSession, 'approval', 0) - - expect(presenter.forSession(sid('first')).getSnapshot()).toEqual([first]) - expect(presenter.forSession(sid('second')).getSnapshot()).toEqual([secondSession]) - expect(() => presenter.present(approval('same', sid('first')), 'approval', 0)) - .toThrow("duplicate pending interaction key 'a:same'") - - removeFirst() - removeFirst() - expect(() => first.respond({ - ok: true, - value: { sessionId: sid('first'), approvalId: 'same', outcome: 'rejected' }, - })).toThrow('already settled') - expect(presenter.forSession(sid('first')).getSnapshot()).toEqual([]) - expect(presenter.forSession(sid('second')).getSnapshot()).toEqual([secondSession]) - removeSecond() - }) - - it('returns stable empty sources for absent and known Sessions', () => { - const presenter = new PendingInteractionPresenter() - const absent = presenter.forSession(undefined) - const first = presenter.forSession(sid('first')) - - expect(presenter.forSession(undefined)).toBe(absent) - expect(absent.getSnapshot()).toEqual([]) - const dispose = absent.subscribe(() => {}) - dispose() - expect(presenter.forSession(sid('first'))).toBe(first) - expect(first.getSnapshot()).toEqual([]) - }) -}) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 7aa90e332a..4633d07e20 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -9,7 +9,6 @@ import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-run import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' -import { PendingInteractionPresenter } from '../src/client/pending-interactions.ts' import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' @@ -29,7 +28,6 @@ async function bench() { const fiber = runtime.ctx.plugin(ConversationController, { input: hub, blocks: new ComposerBlockRegistry(), - pendingInteractions: new PendingInteractionPresenter(), }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationController @@ -127,7 +125,6 @@ describe('ConversationController', () => { await bare.plugin(ConversationController, { input: new InputHub(bare, makeTranslate(zh, {})), blocks: new ComposerBlockRegistry(), - pendingInteractions: new PendingInteractionPresenter(), }).await() const orphan = bare.get('conversation') as ConversationController await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index e882920378..6841e734e7 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useRef, useState } from 'react' import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 38b7f3fb89..879ed682e4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -8,9 +8,11 @@ * the owning view renders an empty chain and inert prose at zero cost. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { @@ -28,7 +30,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale', 'conversationEvents', 'connection'] +export const inject = ['slots', 'locale', 'uiConversation', 'connection'] /** * Client plugin body: register the dictionaries and the turn-tail entry. @@ -36,7 +38,7 @@ export const inject = ['slots', 'locale', 'conversationEvents', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - ctx.conversationEvents.register(deliverablesDefinition) + ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index e63bca2e63..604ab099f1 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,12 +3,10 @@ * model-free: the vocabulary is the mutation tools' own follow-along * `locations`, never the closing prose. */ -import type { - ConversationNodeDefinition, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { ToolResultNode, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' interface ProducedPath { readonly seq: number @@ -20,7 +18,7 @@ export interface DeliverablesTurnData { readonly produced: readonly ProducedPath[] } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationTurnDataMap { /** Successful mutation paths accumulated in this Turn. */ deliverables: DeliverablesTurnData diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 7ca5fd4e6d..7aef3b3580 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -9,15 +9,16 @@ import { Context } from '@deepseek-ai/cordis' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { - ConversationEventRegistry, ConversationNodeAssembler, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationNodeAssembler, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, ConversationViewNode, TurnLocation, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { fitProducedFiles, ProducedFiles, type ProducedFilesProps, @@ -115,7 +116,7 @@ function at( seq, time: seq * 1_000, type, data, ...(type === 'tool/result' ? { surfaceOp: 'append' } : {}), } as ConversationEventInput['event'], - view, + ...(view === undefined ? {} : { view }), } } @@ -456,7 +457,7 @@ describe('plugin registration', () => { it('registers the tail entry and fiber disposal removes it', async () => { const ctx = new Context() await ctx.plugin(SlotRegistry).await() - await ctx.plugin(ConversationEventRegistry).await() + new UiConversation(ctx, { binding: () => undefined } as never) // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index ca64849943..3266bd88af 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -40,8 +40,8 @@ import { Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' -import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' diff --git a/packages/client/ui-directory-picker-browse/src/client/flow.ts b/packages/client/ui-directory-picker-browse/src/client/flow.ts index 84e49b2c98..dbe80e9415 100644 --- a/packages/client/ui-directory-picker-browse/src/client/flow.ts +++ b/packages/client/ui-directory-picker-browse/src/client/flow.ts @@ -5,7 +5,7 @@ */ import { createElement } from 'react' import type { ReactElement } from 'react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' diff --git a/packages/client/ui-directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker-browse/src/client/index.ts index 48f9c14309..8730d48098 100644 --- a/packages/client/ui-directory-picker-browse/src/client/index.ts +++ b/packages/client/ui-directory-picker-browse/src/client/index.ts @@ -7,17 +7,19 @@ * cordis.yml row; no client code branches on a capability kind. The dialog's * copy is locale-registered here — the flow package owns its own strings. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { BrowseFlowInjected } from './flow.ts' import { BrowseDirectoryFlow } from './flow.ts' /** Locale namespace owning the browser dialog's copy. */ const LOCALE_NS = 'directory-browser' -/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */ -export const inject = ['slots', 'workspaces', 'locale'] +/** Required services (cordis fiber inject): the slot registry, workspace UI service, and locale. */ +export const inject = ['slots', 'uiWorkspace', 'locale'] /** * Client plugin body: register the dialog's dictionaries and the browse flow @@ -73,8 +75,8 @@ export function apply(ctx: ClientContext): void { }, 'directory-picker-browse: dialog dictionaries') const injected = (): BrowseFlowInjected => ({ - listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), - createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), + listDirectory: (path, signal) => ctx.uiWorkspace.listDirectory(path, signal), + createDirectory: (path, name) => ctx.uiWorkspace.createDirectory(path, name), t: ctx.locale.bind(LOCALE_NS), }) // Both declaration lifetimes must be live before the pair installs; the diff --git a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx index 5cf7043b65..6008e58ddc 100644 --- a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx @@ -2,8 +2,8 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -34,7 +34,7 @@ async function bench() { ctx.provide('locale', new LocaleRuntime(ctx)) const listDirectory = vi.fn(async (): Promise => homeListing) const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) - ctx.provide('workspaces', { listDirectory, createDirectory } as never) + ctx.provide('uiWorkspace', { listDirectory, createDirectory } as never) const slots = ctx.get('slots') as SlotRegistry const declare = () => slots.register({ name: 'root', @@ -53,7 +53,7 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-browse client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'workspaces', 'locale']) + expect(inject).toEqual(['slots', 'uiWorkspace', 'locale']) }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx index 584f791290..6c1d35a76b 100644 --- a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' -import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index af220fe5ba..951efc2356 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -7,15 +7,17 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { NativeFlowInjected } from './flow.ts' import { NativeDirectoryFlow } from './flow.ts' -/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */ -export const inject = ['slots', 'workspaces'] +/** Required services (cordis fiber inject): the slot registry and workspace UI service. */ +export const inject = ['slots', 'uiWorkspace'] /** * Client plugin body: register the renderless native flow into both @@ -24,7 +26,7 @@ export const inject = ['slots', 'workspaces'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) + const injected = (): NativeFlowInjected => ({ pick: () => ctx.uiWorkspace.pickDirectory() }) // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 39d851610a..2cfd1350ca 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' @@ -17,7 +17,7 @@ async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') - ctx.provide('workspaces', { pickDirectory } as never) + ctx.provide('uiWorkspace', { pickDirectory } as never) const slots = ctx.get('slots') as SlotRegistry const declare = () => slots.register({ name: 'root', @@ -36,7 +36,7 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-native client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'workspaces']) + expect(inject).toEqual(['slots', 'uiWorkspace']) }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts index 7d58a5305d..526dc0958d 100644 --- a/packages/client/ui-goal/src/client/goal-command-input.ts +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -3,7 +3,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type {} from '@deepseek-ai/dsh-commands/types' import type { ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Goal-owned human command input projected independently of model messages. */ export interface GoalCommandInputData { @@ -12,7 +12,7 @@ export interface GoalCommandInputData { readonly time: number } -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '@deepseek-ai/dsh-client-ui-chat/client' { interface ChatNodeDataMap { /** Human-entered `/goal` command input. */ 'command-input': GoalCommandInputData diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index bfa1a283b5..47286c07a9 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -8,13 +8,22 @@ * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' -// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). +// Type-only: pulls the Session Controller service used for projected goal state. +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +// Type-only: pulls the Chat node slot and its keyed data map. +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +// Type-only: pulls the Conversation service and input-dock slot. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the renderer-owned slots service. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: pulls the Session standard useProjection seat. +import type {} from '@deepseek-ai/dsh-client-ui-session/client' // Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet). import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client' import type { GoalActionResult, GoalBarActions } from './slots.ts' @@ -38,14 +47,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'goal' /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ -export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'uiConversation'] /** * Client plugin body: the GoalBar dock entry with its mutation verbs. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(goalCommandInputDefinition) + ctx.uiConversation.events.register(goalCommandInputDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ diff --git a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx index 32b3ccd9cf..cb2cf0a4be 100644 --- a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx @@ -14,8 +14,9 @@ import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' -import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -52,7 +53,18 @@ async function bench(options: { } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] - const conversationEvents = new ConversationEventRegistry(ctx) + const sessions = { + binding: (id: SessionId) => ({ + sessionId: id, + session: { projections: { faceOf: (key: string) => ({ + getSnapshot: () => (key === 'goal' ? options.projection : undefined), + subscribe: () => () => {}, + }) } }, + ctx, + }), + } + ctx.provide('sessions', sessions) + const conversationEvents = new UiConversation(ctx, sessions as never).events function answer(method: string, value: T) { return (...args: unknown[]) => { calls.push({ method, args }) @@ -88,16 +100,6 @@ async function bench(options: { }, } as never, (() => null) as never) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('sessions', { - binding: (id: SessionId) => ({ - sessionId: id, - session: { projections: { faceOf: (key: string) => ({ - getSnapshot: () => (key === 'goal' ? options.projection : undefined), - subscribe: () => () => {}, - }) } }, - ctx, - }), - }) const fiber = ctx.plugin({ inject: [...inject], apply }) return { ctx, diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index c867e516d6..811e922118 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -2,15 +2,17 @@ import { cleanup, render, within } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { - ChatConversationViewNode, ChatSnapshot, ConversationEventInput, - ConversationNodeDefinition, ConversationViewDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' + ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ChatConversationViewNode, ChatSnapshot, +} from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts' -import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts' +import { commandDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/command.ts' +import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts' import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx' import { goalCommandInputDefinition, goalCommandText, @@ -38,7 +40,6 @@ class TestViewDefinitions { function entry(seq: number, type: string, data: unknown): ConversationEventInput { return { event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'], - view: undefined, } } diff --git a/packages/client/ui-input-trigger/src/client/contract.ts b/packages/client/ui-input-trigger/src/client/contract.ts index 887ef1cb18..291844247a 100644 --- a/packages/client/ui-input-trigger/src/client/contract.ts +++ b/packages/client/ui-input-trigger/src/client/contract.ts @@ -4,7 +4,7 @@ * see registerSource alone, the conversation wiring layer resolves its * per-session controller through sessionOf. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { InputTriggerSource } from '../types.ts' import type { InputTriggerController } from './controller.ts' diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 68bb5a15cc..b2507cfc12 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -7,8 +7,9 @@ * only the source roster. One controller per session scope; the service * disposes it with the scope fiber. */ -import type { ClientContext, SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { detectTrigger } from '../core/detect.ts' import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts' import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts' diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 0e7f1af099..64f48ea64a 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -6,7 +6,10 @@ */ // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { InputTriggerService } from './service.ts' import type { MenuViewInjected } from './slots.ts' import { MenuView } from './MenuView.tsx' @@ -57,7 +60,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-input-trigger: menu dictionaries') ctx.inject(['slots', 'inputTriggers', 'sessions'], (scope: ClientContext) => { const inputTriggers = scope.inputTriggers - const sessions = scope.sessions + const sessions = resolveClientSessions(scope) scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'slash-menu', diff --git a/packages/client/ui-input-trigger/src/client/service.ts b/packages/client/ui-input-trigger/src/client/service.ts index 93640dabae..a22627b398 100644 --- a/packages/client/ui-input-trigger/src/client/service.ts +++ b/packages/client/ui-input-trigger/src/client/service.ts @@ -7,7 +7,9 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerSource } from '../types.ts' import { InputTriggerController } from './controller.ts' import type { InputTriggerServiceContract } from './contract.ts' diff --git a/packages/client/ui-input-trigger/src/client/slots.ts b/packages/client/ui-input-trigger/src/client/slots.ts index 5c1f2e3182..fd85856961 100644 --- a/packages/client/ui-input-trigger/src/client/slots.ts +++ b/packages/client/ui-input-trigger/src/client/slots.ts @@ -1,30 +1,8 @@ -/** - * Overlay-slot contract surface of the slash plugin. The - * 'conversation.input.overlay' slot is OWNED by the ui-conversation composer - * entry (declaring is claiming: anchor, children declaration, lifecycle), - * but the SlotMap type merge lives here: the owner package depends on this - * one, so the dependency direction admits no reverse type import, and a - * type-erased registration is ruled out. The owner's - * program picks this merge up transitively through its ui-input-trigger imports. - */ -// Type-only edge: the SlotMap augmentation below merges into this package's interface. -import type {} from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +/** Slash-menu props for the Conversation-owned input overlay. */ +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { MenuState } from '../core/contract.ts' -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface SlotMap { - /** - * The InputBar floating overlay anchor: MenuView (this package) and the - * popupSelect shell (ui-commands) contribute list entries; each reads its - * own store and renders null while closed. Declared (children table) by - * ui-conversation's composer entry; the anchor hides with the input - * under a takeover. - */ - 'conversation.input.overlay': { kind: 'list'; scope: 'session' } - } -} - /** Injected business face of the MenuView overlay entry (copy rides the standard locale seat, not this face). */ export interface MenuViewInjected { /** The service's menu state store (read-only here; MenuView subscribes). */ diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 5f2dd6dfe6..025beadd09 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -1,14 +1,22 @@ /** - * Frozen cross-package contract for the input trigger pipeline. Types only — - * no runtime code. Sources (ui-commands / ui-skill / ui-reference) and the - * conversation input layer import from here; changes require main-thread - * arbitration. + * Input-trigger provider contract. Types only — no runtime code. The + * conversation input layer owns and exports the shared machine currency; + * this module re-exports it for trigger providers. * * Providers receive a {@link ClientSessionContext} projection per call — * never a Cordis context or the mutable Session. RPC and service access go * through the provider plugin's own root context captured at registration. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + PickOutcome, TokenSpan, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +export type { + ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, + InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment, + SubmitOutcome, TokenSpan, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** * The provider-facing projection of one client session. It carries stable @@ -41,85 +49,6 @@ export interface InputTriggerCandidate { readonly value?: string } -/** Pick-moment snapshot of the trigger token span. CAS: stale draftRev ⇒ the whole action no-ops. */ -export interface TokenSpan { - readonly start: number - readonly end: number - readonly draftRev: number -} - -/** Base64-encoded composer image accompanying one claimed submit transaction. */ -export interface SubmitImageAttachment { - /** Declared media type; the host verifies it against the decoded bytes. */ - readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' - /** Canonical base64 encoding of the image bytes. */ - readonly data: string - /** Optional display name; never interpreted as a path. */ - readonly name?: string -} - -/** - * Command-mode entry credential. Pure data + a closure method — no class, no - * cross-package runtime value (client bundle purity). - */ -export interface CommandClaim { - /** Integrity-watched draft prefix, e.g. `'/goal '` — breaking startsWith releases the claim. */ - readonly token: string - /** Ghost-text hint rendered while the claim's args are blank. */ - readonly hint?: string - /** - * Whether composer image attachments may accompany this command's submit. - * Absent = the composer refuses to submit while images are attached, keeping - * the draft and the images in place behind a visible notice. - */ - readonly images?: boolean - /** - * Enter transaction, supplied by the source as a closure. - * @param images - serialized composer images accompanying the submission; - * the composer passes them only when {@link CommandClaim.images} is true. - */ - submit(args: string, actx: ClientContext, images: readonly SubmitImageAttachment[]): Promise -} - -/** - * Inline reference insertion. The draft holds the complete display text while - * the occurrence retains its range; the owner supplies both user-facing projections at insert time - * (the model representation is serialized on submit via the source codec). - */ -export interface ReferenceInsert { - readonly source: string - readonly ref: string - /** Inline display label (fallback-cached on the occurrence). */ - readonly label: string - /** Optional domain glyph shown beside the label. */ - readonly appearance?: 'session' | 'file' | 'folder' - /** Clipboard / persistence projection, e.g. `/name` (never the model form). */ - readonly clipboardText: string -} - -/** Settled result of a command submit transaction. */ -export interface SubmitOutcome { - readonly kind: 'success' | 'error' - readonly text?: string -} - -/** - * Unified pick return. `undefined` = miss → default sink; `'handled'` = the - * source dealt with it internally (e.g. opened its popup shell). The `text` - * arm is the plain-text reference path (decision recorded in - * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): - * the token span is - * replaced with literal text — no occurrence identity, no placeholder; any - * chip visual is derived downstream by scanning the draft against the - * source lexicons. - */ -export type PickOutcome = - | { readonly claim: CommandClaim } - | { readonly insert: ReferenceInsert } - | { readonly text: string; readonly continue?: boolean } - | 'handled' - | undefined - /** * Non-text composer submission state visible to enter adjudication. The * composer owns the actual attachment payloads; adjudication only needs their @@ -235,73 +164,3 @@ export interface TriggerGuard { /** plain: '/' and '@' live; claimed: '/' suppressed, '@' live; frozen: none. */ readonly tier: 'plain' | 'claimed' | 'frozen' } - -/** Keys the menu intercepts while open (all behind the IME composition guard). */ -export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' - -/** consumed = key handled; pick-highlighted = enter picked the highlight; pass = let the input see it. */ -export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass' - -/** Request payload of the scoped begin-command input event. */ -export interface BeginCommandRequest { - readonly claim: CommandClaim - readonly span: TokenSpan -} - -/** Request payload of the scoped insert-reference input event. */ -export interface InsertReferenceRequest { - readonly reference: ReferenceInsert - readonly span: TokenSpan -} - -/** Request payload of the scoped consume-token input event. */ -export interface ConsumeTokenRequest { - readonly guard: - | { readonly kind: 'span'; readonly span: TokenSpan } - | { readonly kind: 'bare-token'; readonly token: string } -} - -/** Request payload of the scoped insert-text input event (the plain-text reference path). */ -export interface InsertTextRequest { - /** Literal replacement for the trigger token span (e.g. `/name `). */ - readonly text: string - readonly span: TokenSpan - /** Keep completion open after the splice (directory descent): the input re-tracks at the caret. */ - readonly continue?: boolean -} - -declare module '@deepseek-ai/cordis' { - interface Events { - /** - * Applies one command claim to the scoped Input. Dispatched with the - * session's scope carrier; the owning session's input listener returns - * `true` only after the phase and span CAS checks pass and the machine - * actually mutated — producers treat anything else as "not applied". - * @param request - Claim and menu-time span CAS. - * @mode bail - */ - 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined - /** - * Inserts one reference into the scoped Input (same carrier routing and - * applied-truth contract as begin-command). - * @param request - Reference and menu-time span CAS. - * @mode bail - */ - 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined - /** - * Consumes one command token after business success (popup settle / - * menu-pick execute). Same carrier routing and applied-truth contract. - * @param request - Exact span or bare-token guard. - * @mode bail - */ - 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined - /** - * Replaces the trigger token span with literal text — the plain-text - * reference path. Same carrier routing and applied-truth - * contract; the draft gains ordinary characters, no occurrence entry. - * @param request - Replacement text and menu-time span CAS. - * @mode bail - */ - 'slash/input-insert-text'(request: InsertTextRequest): true | undefined - } -} diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index e66fb73262..43f1012993 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -7,8 +7,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { + createScope, resolveClientSessions, scopeOf, +} from '@deepseek-ai/dsh-api-session-controller/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -77,7 +80,7 @@ describe('apply', () => { const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected const injected = injectEntry(sid('a')) const controller = inputTriggers.sessionOf( - (ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')), + resolveClientSessions(ctx).scope(sid('a'))!, ) expect(injected.menu).toBe(controller.menu) // The pick face routes into the controller pipeline (closed menu → no-op). diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index 5412d2d7e7..848aaa033d 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index a03839d7bd..b936e64c5f 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -9,8 +9,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome, diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 8834abf82b..bf15074079 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { JobView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' import { IconChevronDownOutline14, StateDot, useDismissOnOutsidePointer, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 658f080702..22dc2c8425 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -4,9 +4,11 @@ * through the `jobsBySession` list mirror, so the plugin issues no RPC and * holds no state of its own beyond popover visibility. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import { JobListAction } from './JobListAction.tsx' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { en, NS, zh, type JobKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 18e19d7e40..93e6df1e5f 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' @@ -93,7 +93,9 @@ describe('ui-job invariant companion', () => { expect(JobInvariant.name).toBe('client-ui-jobs-invariant') expect(JobInvariant.inject).toEqual(['invariants']) // Emitting an unrelated event proves the companion installed no audit. - expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow() + expect(() => { + Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) + }).not.toThrow() await fiber.dispose() }) }) diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 399ad4aa1c..c3f016d58e 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionId, SessionListState, JobView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 2696dc91fa..efca05a73c 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import type { ReactNode } from 'react' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts' +import { DocumentTitle } from './DocumentTitle.tsx' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -89,12 +90,17 @@ export function AppFrame({ useSessions, actions, renderSlot, + SessionProvider, }: AppFrameProps) { const panels = useStore(s => s) const detailsSession = useSessions((s) => { const current = s.current return current !== undefined && s.byId[current]?.blank === false ? current : undefined }) + const documentTitle = useSessions((s) => { + const current = s.current + return current === undefined ? undefined : s.byId[current]?.title + }) const frameRef = useRef(null) const [viewport, setViewport] = useState(() => window.innerWidth) @@ -170,6 +176,7 @@ export function AppFrame({ data-details-collapsed={cols.details === 0 || undefined} data-dragging={dragging || undefined} > +
{/* Render-site slot call with live concession output: a closed sidebar keeps the mounted slot at the compact-rail width, and the @@ -185,10 +192,12 @@ export function AppFrame({ {/* Both column occupants stay at fixed tree positions from first paint — no loading gate: a bare status line reads worse than the shell's own pending rendering. The conversation - is session-maybe; the strict details entry naturally renders - empty while no session is current. */} + is session-maybe; SessionProvider withholds the strict details + entry while no session is current. */} {renderSlot('conversation', {})} - {renderSlot('details', {})} + + {renderSlot('details', {})} +
{renderSlot('shell.overlay', {})} diff --git a/packages/client/ui-renderer/src/client/DocumentTitle.tsx b/packages/client/ui-layout/src/client/DocumentTitle.tsx similarity index 100% rename from packages/client/ui-renderer/src/client/DocumentTitle.tsx rename to packages/client/ui-layout/src/client/DocumentTitle.tsx diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index c56d83bfbb..cecf06deb8 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -7,7 +7,9 @@ * with the runtime sessions service. A second effect seats the theme * presenter, which projects ctx.theme snapshots onto document.body. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type { PanelActions } from './service.ts' import { AppFrame } from './AppFrame.tsx' diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index d2de668a9c..7b6e2807ee 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -7,7 +7,7 @@ * derives its PropsStore share from the return type, and the service face * receives the bound actions through the registration's inject hook. */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import { clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index d865a5c186..fe55ca922e 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -2,7 +2,7 @@ /** * AppFrame interaction spec under the four-share props form: real layout * store instance (createLayoutStore().create() — the test-sanctioned engine - * path), a recording renderSlot stub, and a render-prop SessionProvider stub + * path), a recording renderSlot stub, and a SessionProvider component stub * (the real one is framework-wired to the renderer host; its own behavior is * ui-renderer's spec territory). Drag sequences (pointer capture + rAF flush), * concession response to viewport change, and details staying mounted at @@ -17,22 +17,24 @@ import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame. import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' -import type { - SessionId, SessionListState, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Session selection controls for the SessionProvider and useSessions stubs. const selectedSession = { current: 's-test' as SessionId | undefined } const selectedSessionBlank = { current: false } -const baselinesReady = { current: true } +const selectedSessionTitle = { current: undefined as string | undefined } +const workspacesReady = { current: true } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention) -// Render-prop contract stub fed through the standard seat prop (the renderer -// injects the real one in production): session mode runs children(id), empty -// mode runs the empty branch — the frame must work against exactly this -// shape. Typed as the seat's own component type so the branded sessionId -// parameter stays contract-checked. +// Provider contract stub fed through the standard seat prop (the renderer +// injects the real one in production): session mode renders children and +// empty mode runs the empty branch. const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) => - selectedSession.current === undefined ? <>{empty?.() ?? null} : <>{children(selectedSession.current)} + selectedSession.current === undefined ? <>{empty?.() ?? null} : <>{children} /** Observer stub: captures the callback so tests can fire resizes manually. */ @@ -70,15 +72,24 @@ function mountFrame() { ids: current === undefined ? [] : [current], byId: current === undefined ? {} - : { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } }, + : { + [current]: { + id: current, + displayTitle: 'Test', + running: false, + blank: selectedSessionBlank.current, + updatedAt: 1, + ...(selectedSessionTitle.current === undefined ? {} : { title: selectedSessionTitle.current }), + }, + }, current, phase: 'ready', } as SessionListState return sel(sessionState) }) as never - const workspaceState: WorkspaceListState = { + const workspaceState: WorkspaceSnapshot = { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, + ...(workspacesReady.current ? {} : { state: 'loading' as const, phase: 'pending' as const }), } const element = () => ( unknown) => sel(workspaceState)) as never} + useSessionPendingInteraction={useSessionPendingInteraction} + useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as never} SessionProvider={SessionProviderStub} /> ) @@ -114,7 +126,8 @@ beforeEach(() => { frameWidth = 1920 selectedSession.current = 's-test' as SessionId selectedSessionBlank.current = false - baselinesReady.current = true + selectedSessionTitle.current = undefined + workspacesReady.current = true vi.useFakeTimers() vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number) @@ -132,11 +145,28 @@ beforeEach(() => { afterEach(() => { cleanup() + document.title = '' vi.useRealTimers() vi.unstubAllGlobals() + vi.unstubAllEnvs() }) describe('AppFrame', () => { + it('projects the selected durable Session title', () => { + vi.stubEnv('DSH_CLIENT_TITLE', 'Product') + selectedSessionTitle.current = 'First' + const { rerenderFrame } = mountFrame() + expect(document.title).toBe('First — Product') + + selectedSessionTitle.current = 'Revised' + act(() => { rerenderFrame() }) + expect(document.title).toBe('Revised — Product') + + selectedSession.current = undefined + act(() => { rerenderFrame() }) + expect(document.title).toBe('Product') + }) + it('renders three tracks from store state', () => { const { frame } = mountFrame() expect(tracks(frame)).toEqual([280, 0]) @@ -158,15 +188,17 @@ describe('AppFrame', () => { // No current session: the session-maybe conversation shell owns the New // Session view itself — the center column renders it unconditionally. selectedSession.current = undefined - const { slotCalls, getByTestId } = mountFrame() + const { slotCalls, getByTestId, queryByTestId } = mountFrame() expect(getByTestId('center-content')).toBeTruthy() expect(slotCalls.map(c => c.key)).toContain('conversation') + expect(queryByTestId('details-content')).toBeNull() + expect(slotCalls.map(c => c.key)).toContain('details') }) it('renders both column occupants before baselines settle (no loading gate)', () => { // No loading gate: a bare loading status reads worse than the shell's own // pending rendering — both occupants mount from first paint. - baselinesReady.current = false + workspacesReady.current = false const { slotCalls } = mountFrame() expect(slotCalls.map(c => c.key)).toContain('conversation') expect(slotCalls.map(c => c.key)).toContain('details') diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index f8f4dd3c72..d45a62c046 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' @@ -94,7 +94,7 @@ describe('ui-layout client apply', () => { expect(ctx.get('layout')).toBeUndefined() expect(slots.entries('root')).toHaveLength(0) expect(slots.spec('sidebar')).toBeUndefined() - // The built-in root declaration survives entry teardown (runtime-owned). + // The built-in root declaration survives entry teardown (renderer-owned). expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) }) }) diff --git a/packages/client/ui-renderer/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx similarity index 100% rename from packages/client/ui-renderer/tests/document-title.client.spec.tsx rename to packages/client/ui-layout/tests/document-title.client.spec.tsx diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 92b7c4dda7..29bbfede8d 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -9,7 +9,8 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { MessageFeedbackDeleteResult, MessageFeedbackItem, diff --git a/packages/client/ui-message-feedback/src/client/index.ts b/packages/client/ui-message-feedback/src/client/index.ts index d602ac2bb0..8f88632ec4 100644 --- a/packages/client/ui-message-feedback/src/client/index.ts +++ b/packages/client/ui-message-feedback/src/client/index.ts @@ -7,13 +7,18 @@ * @module @deepseek-ai/dsh-client-ui-message-feedback/client */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { MessageFeedbackController } from './controller.ts' import { MessageFeedbackActions } from './MessageFeedbackActions.tsx' import type { MessageFeedbackInjected } from './slots.ts' diff --git a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx index da51016468..bd894ebacf 100644 --- a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx @@ -11,7 +11,8 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import { cleanup } from '@testing-library/react' -import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types' diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index f6ef9f5ed2..dc4c4fb392 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -10,8 +10,8 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 74c0b97d6a..a2359a87b3 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -13,12 +13,15 @@ */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-session-controller/types' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelDirectoryState } from './directory.ts' import { ModelDirectoryResolver } from './service.ts' @@ -122,7 +125,7 @@ export function apply(ctx: ClientContext): void { ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => { const command = scope.get('commandUi') as CommandUiContract const models = scope.modelDirectories - const sessions = scope.sessions + const sessions = resolveClientSessions(scope) scope.effect(() => command.register({ name: 'model', description: t('command.description'), @@ -153,7 +156,7 @@ export function apply(ctx: ClientContext): void { // Entry 2: the composer's named model seat over the SAME directory. ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => { const models = scope.modelDirectories - const sessions = scope.sessions + const sessions = resolveClientSessions(scope) scope.slots.inject('conversation.input.model', () => scope.slots.register({ name: 'conversation.input.model', locale: NS, diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index 3dcd457793..ce981f6d14 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -14,8 +14,8 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionRuntime } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ModelDirectory } from './directory.ts' declare module '@deepseek-ai/cordis' { @@ -70,7 +70,7 @@ export class ModelDirectoryResolver extends Service { const { live } = this const existing = live.directories.get(sessionId) if (existing !== undefined) return existing - const sessions = this.ctx.get('sessions') as SessionRuntime + const sessions = resolveClientSessions(this.ctx) const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`) const directory = new ModelDirectory( diff --git a/packages/client/ui-model-selection/src/client/slots.ts b/packages/client/ui-model-selection/src/client/slots.ts index 91124a81f6..3924b5a95f 100644 --- a/packages/client/ui-model-selection/src/client/slots.ts +++ b/packages/client/ui-model-selection/src/client/slots.ts @@ -5,7 +5,7 @@ * merge lives here. */ import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ModelDirectoryState } from './directory.ts' /** Injected business face of the composer model seat. */ diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index fc4b765adc..795d2487be 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -10,8 +10,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { createScope } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types' diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index ea9c2f7abb..166eb6d74e 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' diff --git a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx index ae8c8bafe7..801c8f7cb9 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx +++ b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu, RiskConfirmation, diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 5248dc31d9..e7d346cfef 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -13,15 +13,18 @@ * The General-settings row separately writes the default preset for sessions * created later through the host Settings API. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import { resolveClientSessions, type SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings slot types (this package registers a General row). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (the settings invalidation rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' @@ -80,7 +83,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO */ export function apply(ctx: ClientContext): void { const command = ctx.get('commandUi') as CommandUiContract - const sessions = ctx.sessions + const sessions = resolveClientSessions(ctx) // This optional bundle and ui-conversation can load independently, so each // owns the same safety copy under its own locale namespace. /* jscpd:ignore-start */ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 89007c36e6..2f78d7c4b3 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -11,7 +11,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-store' import type { SchemaNode, SettingsDescribeFace, SettingsSchemaService, } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index f1570eec9c..ef41a39c00 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -10,7 +10,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 84a9065792..5ab109883c 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -49,8 +49,12 @@ function ok(value: T) { const dictionary: Record = en const t: PermissionRowProps['t'] = key => dictionary[key] ?? key +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInteraction'] = selector => selector(noAttention) const runtime = { useSessions: (() => { throw new Error('unused') }) as never, + useSessionPendingInteraction, useWorkspaces: (() => { throw new Error('unused') }) as never, } diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 312db17222..2374748429 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -9,7 +9,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 3e813b9e11..5092af1931 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -6,7 +6,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 574bc8e2c3..d1342e9b1d 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,7 @@ * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: the settings slot declarations plus the ctx.settingsScope Context @@ -16,6 +16,8 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, } from './shell-contract.ts' diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index c6e2109f4d..b545ec66f3 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -1,7 +1,7 @@ /** State owner for the optional local settings-document action. */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** Browser state of the Host-owned settings document. */ diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index a0f0bb392e..c91b608509 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 9dc6043004..286a2d52c9 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -25,7 +25,10 @@ const t: TriggerContentProps['t'] = key => (en as Record)[key] ? // Global standard kit stubs: none of these components consume the hooks. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never -const kit = { useSessions: unusedHook, useWorkspaces: unusedHook } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention) +const kit = { useSessions: unusedHook, useSessionPendingInteraction, useWorkspaces: unusedHook } describe('chrome content', () => { it('TriggerContent renders the icon with the label in the wide column', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index c9ec593974..2ef00fc585 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -18,6 +18,10 @@ const SEAT_CONTENT: Record = { 'settings.close': 'Close', } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function mount({ wide = true, onboardingActive = true, @@ -51,6 +55,7 @@ function mount({ const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never const props: SettingsRootComponentProps = { useSessions, + useSessionPendingInteraction, useWorkspaces: unusedHook, wide, useOnboardingSteps: select => select(steps), diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 2b50a33596..1c5b447c62 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -1,7 +1,7 @@ /** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '../src/client/index.ts' import type { SettingsRootInjected } from '../src/client/shell-contract.ts' diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 9e6cbb4c2a..c8fe63c368 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { onboardingReadiness } from './store.ts' diff --git a/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx index 2a10873b9e..3442ab508a 100644 --- a/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { ReactNode } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts' diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 90a2aed75a..e24e2774a3 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -6,12 +6,13 @@ * Export discipline: * packages/client/AGENTS.md. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (settings/credentials invalidations ride the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 5b688db919..349798acdd 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -9,8 +9,8 @@ import type { ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SettingsSchemaOperations } from './schema-operations.ts' diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 82b8e0a662..9fe4ff3113 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -5,8 +5,8 @@ * stays process-local here. */ -import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION, } from '../onboarding-copy.ts' diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 6a36ccbf20..a3c8a1a8ea 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index d8468f2270..e849904345 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -41,6 +41,10 @@ const DeepSeekConfig = Schema.object({ })), }) +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: DeepSeekOnboardingDialogProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView { const value = apiKeyEnv === null ? {} : { apiKeyEnv } return { @@ -135,6 +139,7 @@ function harness(options: { complete, openSection, useSessions: unusedHook, + useSessionPendingInteraction, useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index bf2674976a..244bdcf78a 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -41,6 +41,10 @@ function welcomeView(value: unknown, revision = 0) { } } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: WelcomeNoticeProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function mount( version?: string, mutateImpl: () => Promise = () => @@ -77,6 +81,7 @@ function mount( complete, openSection: vi.fn(), useSessions: unusedHook, + useSessionPendingInteraction, useWorkspaces: unusedHook, controller, useWelcome: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index ccfd8bda5a..6cf1813536 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -1,8 +1,9 @@ /** Read-only Host plugin inventory registered into Web Settings. */ import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx' import { en, zh, type PluginInventoryLocaleKey } from './locales.ts' diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx index b955db8a5b..f54ee5fb6c 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx @@ -3,7 +3,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, NS } from '../src/client/index.ts' diff --git a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts b/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts index c09b78c057..d37d59b394 100644 --- a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts @@ -1,6 +1,7 @@ /** The agent-loop card's staged form over the `agent-loop` settings namespace. */ -import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' /** diff --git a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts index e2cde532df..3ad8c6fefe 100644 --- a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts @@ -1,6 +1,7 @@ /** The shell card's staged form over the `bash` settings namespace. */ -import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' /** diff --git a/packages/client/ui-settings-plugins/src/client/card-form.ts b/packages/client/ui-settings-plugins/src/client/card-form.ts index 255450bdb9..365a2e8fd8 100644 --- a/packages/client/ui-settings-plugins/src/client/card-form.ts +++ b/packages/client/ui-settings-plugins/src/client/card-form.ts @@ -13,8 +13,8 @@ * override equal to the composition default is still an override. */ -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' /** The write one field's staged text performs when the card is saved. */ export type FieldWrite = diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index b7f31d6cce..aababb7d89 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -16,7 +16,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' // and the ctx.settingsScope Context merge. Cross-plugin collaboration goes // through the service, never a value import (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts index 5cd26c69fc..00f0b29b9d 100644 --- a/packages/client/ui-settings-plugins/src/client/tab-store.ts +++ b/packages/client/ui-settings-plugins/src/client/tab-store.ts @@ -12,7 +12,7 @@ import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** What the section renders. */ export interface ConfigurablePluginsTabState { diff --git a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts index 53d6428a97..e329359230 100644 --- a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts @@ -10,7 +10,8 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' -import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { CardForm, numberField, textField, type CardActions, type CardFieldState, type CardShell, diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 06ec8f00b2..b1ecdfa132 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 9e9bced1cb..ad666e9093 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' import { BashCard } from '../src/client/BashCard.tsx' diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 5413f8cb29..0ae62f4cb1 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -9,6 +9,7 @@ * ui-settings-general too. */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index f2763da9f7..66eca29c22 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -11,8 +11,10 @@ * ui-sidebar would close a reference cycle through ui-layout and ui-theme. * Export discipline: packages/client/AGENTS.md. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only service merge for the connection lifecycle event. +import type {} from '@deepseek-ai/dsh-client-connection/client' // Type-only pair supplying `$on` and its key face without dragging a build // artifact into the Host graph (rationale beside the same pair in // settings-scope.ts). @@ -27,6 +29,7 @@ export type { SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export type { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' +export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts' export type { SettingsSchemaService } from './schema.ts' export type { SchemaNode } from './schema.ts' export type { SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' @@ -46,7 +49,7 @@ export const inject = ['connection', 'remote'] * bound to each consuming plugin's context. * @param ctx - client root context. */ -export function apply(ctx: ClientContext): void { +export function apply(ctx: Context): void { const schema = new SettingsSchemaService(ctx) const connection = ctx.get('connection') as ConnectionHandle const mirror = new SettingsDescribeMirror( @@ -55,7 +58,7 @@ export function apply(ctx: ClientContext): void { ) ctx.effect(() => { const disposers = [ - (ctx.get('remote') as ClientContext['remote']).$on('settings/document-updated', () => { void mirror.load() }), + ctx.remote.$on('settings/document-updated', () => { void mirror.load() }), ctx.on('connection/reset', () => { void mirror.load() }), ] // The first connection also emits connection/reset, so startup normally diff --git a/packages/client/runtime/src/client/contract/settings-scope.ts b/packages/client/ui-settings/src/client/settings-contract.ts similarity index 88% rename from packages/client/runtime/src/client/contract/settings-scope.ts rename to packages/client/ui-settings/src/client/settings-contract.ts index cb22cb15ed..38f95177a7 100644 --- a/packages/client/runtime/src/client/contract/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -1,10 +1,5 @@ /** - * The settings-namespace scope contract. The type lives here, in the common - * dependency of every feature that owns a preference, while the implementation - * and its Host transport live with the Settings surface - * (`dsh-client-ui-settings`): a feature service accepts a scope through - * `attachSettings` without depending on the surface that binds it, which would - * otherwise close a reference cycle. + * Settings-namespace scope contracts owned beside the settings transport. */ /** Client-side sync state of one settings namespace. */ diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 8e570b6512..81b190e034 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -10,7 +10,7 @@ */ import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' type SettingsFace = Pick diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 36e674c937..a49b8ba707 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -1,9 +1,7 @@ /** - * Host transport for the settings-namespace scope contract. The contract types - * live in `dsh-client-runtime` (the common dependency of every feature that - * owns a preference); this file owns the per-namespace derivation over the - * shared {@link SettingsDescribeMirror} and the serialized write path, both of - * which are Settings-surface concerns. Reads never touch the wire here: the + * Host transport for the settings-namespace scope contract. This file owns the + * per-namespace derivation over the shared {@link SettingsDescribeMirror} and + * the serialized write path. Reads never touch the wire here: the * mirror is the one `settings.describe` reader, and every scope is a selector * over its snapshot. */ @@ -13,10 +11,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' -import { - createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, - type SettingsScopeSpec, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' // Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this // package is reachable from the Host build graph through its feature-package // callers, and api-remotes' Client face imports a Host-tsdown-generated @@ -34,6 +29,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' import type { SettingsSchemaService } from './schema.ts' +import type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts' import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts' type SettingsFace = Pick diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index ddbb6784d1..0f3933f61d 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -3,7 +3,7 @@ import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts' @@ -244,6 +244,7 @@ describe('SettingsScopeController', () => { }) it('keeps the write queue usable when a subscriber throws', async () => { + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) .mockResolvedValueOnce(described({ preference: 'light' }, 2)) @@ -254,12 +255,17 @@ describe('SettingsScopeController', () => { thrown = true throw new Error('subscriber failed') }) - await expect(mirror.load()).rejects.toThrow('subscriber failed') + await expect(mirror.load()).resolves.toBeUndefined() await expect(mirror.load()).resolves.toBeUndefined() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) + expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({ + message: 'subscriber failed', + })) + report.mockRestore() }) it('keeps the write queue usable when a write publication listener throws', async () => { + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1)) const mutate = vi.fn() .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) @@ -273,11 +279,38 @@ describe('SettingsScopeController', () => { throw new Error('write subscriber failed') }) - await expect(scope.set('preference', 'dark')).rejects.toThrow('write subscriber failed') + await expect(scope.set('preference', 'dark')).resolves.toBeUndefined() await expect(scope.set('preference', 'light')).resolves.toBeUndefined() expect(mutate).toHaveBeenCalledTimes(2) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 }) + expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({ + message: 'write subscriber failed', + })) + report.mockRestore() + }) + + it('keeps the write queue usable after a failed mirror fold', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1)) + const mutate = vi.fn() + .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + vi.spyOn(mirror, 'acceptView').mockImplementationOnce(() => { + throw new Error('mirror fold failed') + }) + + await expect(scope.set('preference', 'dark')).rejects.toThrow('mirror fold failed') + await expect(scope.set('preference', 'light')).resolves.toBeUndefined() + + expect(mutate).toHaveBeenCalledTimes(2) + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 1, + }) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 }) }) it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 65f0102543..4b4e2e1538 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -8,10 +8,10 @@ * actions in `sidebar.footer.action`. */ import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index a3bc3ff156..0960a67bb9 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -4,7 +4,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -// Type-only: pulls the Session UI navigation service merge (ctx.uiSession). +// Type-only: pulls the Session root standard-props merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' @@ -26,19 +26,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell controls copy). */ const NS = 'sidebar' +interface WorkspaceNavigation { + startSession(workspaceId?: Parameters[0]): void +} + /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'uiSession', 'locale'] +export const inject = ['slots', 'layout', 'uiWorkspace', 'locale'] /** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') const injectProps = (): SidebarRootInjected => ({ - // The shell's New Session button rides the Session UI's shared action + // The shell's New Session button rides the Workspace UI's shared action // (current Session Workspace, then recent Workspace). - startSession: (workspaceId) => { ctx.uiSession.startSession(workspaceId) }, + startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) ctx.effect( diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index b1f8fa8952..23c2023a8c 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -10,9 +10,9 @@ async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const layout = { toggleSidebar: vi.fn() } - const uiSession = { startSession: vi.fn() } + const uiWorkspace = { startSession: vi.fn() } ctx.provide('layout', layout) - ctx.provide('uiSession', uiSession as never) + ctx.provide('uiWorkspace', uiWorkspace as never) ctx.provide('locale', new LocaleRuntime(ctx)) const slots = ctx.get('slots') as SlotRegistry if (declare) { @@ -21,12 +21,12 @@ async function bench(declare = true) { () => null, ) } - return { ctx, slots, layout, uiSession } + return { ctx, slots, layout, uiWorkspace } } describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'uiSession', 'locale']) + expect(inject).toEqual(['slots', 'layout', 'uiWorkspace', 'locale']) }) it('registers the shell and declares its child seats', async () => { @@ -42,11 +42,11 @@ describe('ui-sidebar apply', () => { expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) - // Both arms delegate to the Session UI's shared New Session action. + // Both arms delegate to the Workspace UI's shared New Session action. injected.startSession('workspace' as never) - expect(b.uiSession.startSession).toHaveBeenCalledWith('workspace') + expect(b.uiWorkspace.startSession).toHaveBeenCalledWith('workspace') injected.startSession() - expect(b.uiSession.startSession).toHaveBeenLastCalledWith(undefined) + expect(b.uiWorkspace.startSession).toHaveBeenLastCalledWith(undefined) injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index dace9db04a..9adc7b51df 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -18,6 +18,9 @@ const COLUMN_HEIGHT = 600 const t: SidebarRootComponentProps['t'] = key => (en as Record)[key] ?? key /** The shell never reads the global hooks; the props share carries them regardless. */ const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: SidebarRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) afterEach(() => { cleanup() @@ -32,7 +35,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { const view = render(
) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 99ae7e4d5e..69a395d61c 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -22,6 +22,9 @@ afterEach(() => { // The shell never reads the global hooks itself, but they ride the standard // props share; stub them as never-called functions. const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: SidebarRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) { const startSession = vi.fn() @@ -35,7 +38,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w const root = () => ( { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456') const { container } = render( options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx index e8d4968af1..b2d10c43d9 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx @@ -34,7 +34,7 @@ afterEach(() => { async function bench(options: { locale?: 'en' } = {}) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('layout', { toggleSidebar: vi.fn() }) - vi.spyOn(runtime.ctx.uiSession, 'startSession').mockImplementation(() => undefined) + runtime.ctx.provide('uiWorkspace', { startSession: vi.fn() } as never) const locale = new LocaleRuntime(runtime.ctx) if (options.locale === 'en') locale.setLocale('en') runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 738034e4e8..c62df1051f 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -30,11 +30,15 @@ * accent row derived only from each logged call/result slice. */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. -import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ConnectionHandle, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { SkillRow } from './SkillRow.tsx' import { en, NS, zh, type SkillKey } from './locales.ts' @@ -68,7 +72,7 @@ export function apply(ctx: ClientContext): void { )) const skills = (ctx.get('connection') as ConnectionHandle).api.skills - const sessions = ctx.get('sessions') as ISessions + const sessions = resolveClientSessions(ctx) // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. const fetches = new Map() diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 6557381aee..293c3d2168 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -15,8 +15,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index 8fbbaef7c0..0124cb56e1 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { SkillRow } from '../src/client/SkillRow.tsx' diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index e82d8adc44..80c9dc714d 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -3,9 +3,11 @@ import { } from 'react' import { createPortal } from 'react-dom' import { - indexSubagentDescendants, type SessionId, type SessionListState, type SessionProjectionMap, - type SessionSummary, type SubagentAddress, type SubagentCatalogSnapshot, -} from '@deepseek-ai/dsh-client-runtime/client' + indexSubagentDescendants, type SessionListState, type SessionProjectionMap, + type SessionSummary, type SubagentCatalogSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index ab53562d5a..538eaf33f0 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -1,13 +1,15 @@ /** Web subagent catalog, navigation, and addressed-session composer owner. */ -import type { - ClientContext, SessionId, SubagentAddress, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { en, NS, zh, type SubagentKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index f9ca6838ab..0fc346f9ee 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -2,10 +2,12 @@ import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it } from 'vitest' -import { - SlotRegistry, type ConversationSnapshot, type SessionId, type SessionListState, - type SessionSummary, type SubagentAddress, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { + SessionListState, SessionSnapshot, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { @@ -115,13 +117,14 @@ describe('apply', () => { .find(entry => entry.component === SubagentReadOnlyComposer)! const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null const owner = ( - subagent: ConversationSnapshot['subagent'] | undefined, + subagent: SessionSnapshot['subagent'] | undefined, running = false, ): ComposerChainProps => ({ - pendingInteraction: undefined, + sessionId: subagent?.address.childSessionId, session: subagent === undefined ? undefined - : ({ subagent, running } as unknown as ConversationSnapshot), + : ({ subagent, running } as SessionSnapshot), + pendingInteraction: undefined, }) expect(select(owner(undefined))).toBeNull() expect(select(owner(null))).toBeNull() diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index d4001659bb..c507acfe1c 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -3,8 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { - SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, -} from '@deepseek-ai/dsh-client-runtime/client' + SessionListState, SessionSummary, SubagentCatalogSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SubagentHeaderLineage, type SubagentHeaderLineageProps, } from '../src/client/SubagentHeaderLineage.tsx' @@ -267,6 +268,28 @@ describe('SubagentHeaderLineage', () => { await advance(120) }) + it('repositions an open catalog after viewport resize and document scroll', () => { + const view = render() + const trigger = screen.getByRole('button', { name: /2 个子代理/ }) + const bounds = vi.spyOn(trigger, 'getBoundingClientRect') + bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect) + hoverCatalog(trigger) + const tree = screen.getByRole('tree') + expect(tree.style.top).toBe('25px') + expect(tree.style.left).toBe('30px') + + bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect) + act(() => { window.dispatchEvent(new Event('resize')) }) + expect(tree.style.top).toBe('75px') + expect(tree.style.left).toBe('80px') + + bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect) + act(() => { document.dispatchEvent(new Event('scroll')) }) + expect(tree.style.top).toBe('95px') + expect(tree.style.left).toBe('100px') + view.unmount() + }) + it('cancels a pending hover when the trigger becomes hidden', async () => { vi.useFakeTimers() const view = render() diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 8d9d8ac6b1..c6522e7ffe 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,14 +7,15 @@ * document. The plugin also registers the Appearance preference row into the * settings General section — the theme feature owns its own settings surface. */ -import type { Context } from '@deepseek-ai/cordis' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration // goes through the service, never a value import (client bundle purity gate). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' @@ -149,7 +150,7 @@ const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([ * preference is `system`. */ export class ThemeRuntime { - private readonly ctx: Context + private readonly ctx: ClientContext private readonly host: SettingsScope private themes: ThemeDefinition[] = [...BUILTIN_THEMES] private preference: ThemePreference @@ -165,7 +166,7 @@ export class ThemeRuntime { * media-query and scope listeners are released through ctx.effect on dispose). * @param host - durable preference scope owned by the same plugin. */ - constructor(ctx: Context, host: SettingsScope) { + constructor(ctx: ClientContext, host: SettingsScope) { this.ctx = ctx this.host = host this.preference = DEFAULT_PREFERENCE diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts index e4c76154e5..b616013ac4 100644 --- a/packages/client/ui-theme/src/client/settings-store.ts +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -3,7 +3,7 @@ * plugin's apply-world change listener is the only writer; the row component * reads via props.useStore. */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import type { ThemePreference } from '../theme-settings.ts' /** Store state mirrored from the theme snapshot. */ diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index d8764a46c6..c42d6aa3b6 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' @@ -23,13 +25,16 @@ function emptySessions() { return bindSnapshotSelector(store) } function emptyWorkspaces() { - const store = createSnapshotStore({ + const store = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: AppearanceRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function mount(preference: ThemePreference = 'system') { // Real store instance — the sanctioned zero-machinery path for tests. const store = createAppearanceRowStore().create() @@ -37,6 +42,7 @@ function mount(preference: ThemePreference = 'system') { const setTheme = vi.fn() const props: AppearanceRowComponentProps = { useSessions: emptySessions(), + useSessionPendingInteraction, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 89397e260c..65b66c2a39 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -3,7 +3,7 @@ * projection into the row store, and HMR collapse recovery. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-theme/tests/invariant.client.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts index b418ce2245..59516d9159 100644 --- a/packages/client/ui-theme/tests/invariant.client.spec.ts +++ b/packages/client/ui-theme/tests/invariant.client.spec.ts @@ -5,7 +5,7 @@ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index a2cc912d0d..dce00a0f54 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,7 +1,9 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' import { ToolDetails } from './tool/ToolDetails.tsx' import { CONVERSATION_NS as NS } from './locale.ts' diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index a6cdd1fa2d..c9206c8cd9 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,7 +1,7 @@ /** Tool UI slot declarations and their composed component props. */ import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 3ed5fb9216..ebb4578281 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -1,6 +1,6 @@ /** Root/subcall Tool composition with one keyed atomic dispatch path. */ import { memo, useMemo, type ReactNode } from 'react' -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts' import { GenericToolCard } from './toolviews/GenericToolCard.tsx' import css from './ToolCallTree.module.css' diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index e79a6979b8..db31705d6a 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -13,7 +13,7 @@ * until the result arrives. * @module */ -import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' +import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index e0609191b7..c1adc23b1c 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -8,7 +8,7 @@ * are derived once. * @module */ -import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from './tool-call-model.ts' @@ -169,10 +169,9 @@ function collapse(body: string, rooted: boolean, separator = '/'): string { * returns a generic fenced card for an execution error or a background * start, whose text and error styling the generic path preserves. * - * Window truncation can drop the call head from a settled result (see - * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal - * result with no call side. That still renders: the command falls back to the - * result view's replacement title, then to an empty command (the prompt line + * Window truncation can drop the call head from a settled `ToolResultNode`, + * leaving a terminal result with no call side. That still renders: the command + * falls back to the result view's replacement title, then to an empty command (the prompt line * draws bare), and the prompt shows no cwd. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param sessionCwd - the session workspace root, which resolves an omitted or diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 02d3d56f30..394c3ee76e 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -9,10 +9,10 @@ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). -import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' -export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' /** Tool-call row variants selected by the generic atomic renderer. */ export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others' diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 44a820a110..f30bb1a3bd 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -3,12 +3,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + apply as applyChat, inject as injectChat, type ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.client.tsx' +import { toolSessionEvents } from './tool-details-render.client.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -68,22 +73,26 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { + runtime.ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // ui-theme's Appearance row binds a durable scope through these two. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('remote', { $on: () => () => {} }) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: vi.fn(async () => {}), + } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, - snapshot: { nodes, chat: toolChatSnapshot(nodes) }, + events: toolSessionEvents(nodes), session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), @@ -91,6 +100,7 @@ async function bench(nodes: ToolResultNode[]) { }) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) return runtime } diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index 4957ed2417..79b4b84c12 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -1,21 +1,20 @@ // @vitest-environment jsdom -import { Context } from '@deepseek-ai/cordis' -import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' -import { - ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, - EMPTY_CONVERSATION_VIEWS, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' +import { cleanup, fireEvent } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, - ToolCallBlock, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { createSlotRenderer } from '@deepseek-ai/dsh-client-test-runtime' + ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + ConversationEventRegistry, ConversationViewRegistry, type ConvViewOwnerProps, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' import { toolChatSnapshot } from './tool-details-render.client.tsx' @@ -28,9 +27,12 @@ class ResizeObserverStub { disconnect(): void {} } -afterEach(() => { +const runtimes: SlotTestRuntime[] = [] + +afterEach(async () => { cleanup() vi.unstubAllGlobals() + for (const runtime of runtimes.splice(0)) await runtime.dispose() }) beforeEach(() => { localStorage.clear() @@ -68,118 +70,71 @@ function snapshotWith( nodes: ToolResultNode[], subCalls: readonly ToolCallBlock[], runningCalls: RunningToolCall[] = [], -): ConversationSnapshot { +): ChatSnapshot { const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), - nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, - runningCalls: nestedRunningCalls, - queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, - openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, - } + return toolChatSnapshot(nestedNodes, nestedRunningCalls) } -/** Test-owned AppFrame role: declares and renders the resident conversation area. */ -type AppRootProps = PropsRenderSlots<'conversation' | 'details'> -function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} +/** Test-owned AppFrame role: declares and renders the Chat view list. */ +type AppRootProps = PropsRenderSlots<'conversation.view'> +const VIEW_OWNER: ConvViewOwnerProps = { + viewRequest: null, + openView: () => {}, + completeViewRequest: () => {}, } +function AppRoot({ renderSlot }: AppRootProps) { + return <>{renderSlot('conversation.view', VIEW_OWNER, { only: 'chat' })} +} + +const ROOT_CHILDREN = { + 'conversation.view': { kind: 'list', scope: 'session' }, +} as const /** - * Same real-stack bench as the toolview-slot spec: SlotRegistry + renderer + - * both owning package applies; fakes only at service boundaries. + * Same real-stack bench as the toolview-slot spec: renderer, Chat target, and + * Tool registrations; fakes only at service boundaries. */ -async function bench(snapshot: ConversationSnapshot) { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotRegistry) - await slotsFiber.await() - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - const slots = ctx.get('slots') as SlotRegistry +async function bench(snapshot: ChatSnapshot) { + const runtime = await SlotTestRuntime.create() + runtimes.push(runtime) + const ctx = runtime.ctx + const chat = createSnapshotStore(snapshot) + const events = new ConversationEventRegistry(ctx) + const views = new ConversationViewRegistry(ctx) + ctx.provide('uiConversation', { + events, + views, + binding: () => ({ target: () => chat }), + } as never) - const session = createSnapshotStore(snapshot) - const list = createSnapshotStore({ - ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, - current: SID, - phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S' }, + snapshot: { running: snapshot.legacy.runningCalls.length > 0 }, }) - const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } - // Provide-channel contributions land in this bundle the way the runtime - // materializes them; the renderer host serves it through provideInfo. - const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } - // Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract), - // materialized on first render after the provide contributions landed. - let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined - const sessionsFake = { - list, - binding: (id: SessionId) => (id === SID - ? { sessionId: SID, session, ctx: { effect: () => {}, on: () => () => {} } } - : undefined), - scope: () => ({ get: () => scoped }), - scopeOf: () => SID, - provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record; props?: Record } }) => { - const contribution = descriptor.resolve(sessionsFake.binding(SID)) - Object.assign(provided.hooks, contribution.hooks ?? {}) - Object.assign(provided.props, contribution.props ?? {}) - return () => {} - }, - provideInfo: (id: string) => (id === SID - ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } - : undefined), - currentProvideInfo: { - getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, - subscribe: () => () => {}, - }, - create: vi.fn(), - open: vi.fn(), - } - ctx.provide('sessions', sessionsFake) - const workspaces = { - list: createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - } - ctx.provide('workspaces', workspaces) - ctx.provide('layout', layout) + const openPath = vi.fn(async () => {}) + ctx.provide('layout', layout as never) + ctx.provide('uiWorkspace', { openPath } as never) ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) - // ui-theme's Appearance row binds a durable scope through these two. - ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) - slots.installLocale(locale) + locale.register(CONVERSATION_NS, { zh: conversationZh, en: conversationEn }) + runtime.slots.installLocale(locale) - slots.install(createSlotRenderer()) - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, AppRoot) - - const fiber = ctx.plugin({ inject: [...injectConversation], apply: applyConversation }) - await fiber.await() - const toolFiber = ctx.plugin({ inject: [...injectTool], apply: applyTool }) - await toolFiber.await() - return { ctx, slots, fiber, toolFiber, session, layout, workspaces } + await runtime.root.declare(ROOT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) + await runtime.mount({ inject: [...injectTool], apply: applyTool }) + return { runtime, layout, openPath } } -function mountApp(slots: SlotRegistry) { - return render(<>{slots.renderSlot('root', {})}) +function mountApp(runtime: SlotTestRuntime) { + return runtime.renderRoot() } describe('run_code sub-calls through the real chat machinery', () => { @@ -190,7 +145,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) // Parent row: the code variant with the model-authored description. const codeRoot = view.container.querySelector('[data-variant="code"]') @@ -214,7 +169,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(13, parent, 3, 'cordis_undefine', { id: 'dyn-2' }, 'Dynamic package dyn-2 was discarded.'), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) const nest = view.container.querySelector('[data-subcalls]')! // Each run-control verb names its act and shows the package id; without the @@ -230,7 +185,7 @@ describe('run_code sub-calls through the real chat machinery', () => { it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], [])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) // The code row is expandable via the whole summary row (body = the program). const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]') expect(toggle).not.toBeNull() @@ -249,7 +204,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]') expect(nested).not.toBeNull() }) @@ -261,11 +216,11 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt') + expect(b.openPath).toHaveBeenCalledWith('notes/demo.txt') }) view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() @@ -277,7 +232,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ] const b = await bench(snapshotWith([], subCalls, [runningCode(parent)])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) const running = view.container.querySelector('[data-variant="code"][data-state="running"]') expect(running).not.toBeNull() const nest = view.container.querySelector('[data-subcalls]') @@ -292,7 +247,7 @@ describe('run_code sub-calls through the real chat machinery', () => { turn: 0, step: 0, time: 21_000, callView: null, subCalls: [], } const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) // The nested row derives 'running' from the RunningToolCall shape — the // same data-state chrome (row sweep) a native in-flight row wears. const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]') @@ -308,7 +263,7 @@ describe('run_code sub-calls through the real chat machinery', () => { content: [], isError: false, callView: null, resultView: null, subCalls: [], } const b = await bench(snapshotWith([plain], [])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) expect(view.container.querySelector('[data-subcalls]')).toBeNull() }) }) diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 61b4fc576b..7c20a7c91f 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index d123a7ef70..89e2ae6529 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -2,24 +2,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' afterEach(cleanup) @@ -28,6 +30,7 @@ type FileMutationRowProps = Parameters[0] const SID = 's1' as SessionId const t = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' @@ -301,7 +304,7 @@ describe('fileMutationToolview registration', () => { }) describe('DetailsPanel diff Output section', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -315,18 +318,22 @@ describe('DetailsPanel diff Output section', () => { subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -339,22 +346,18 @@ describe('DetailsPanel diff Output section', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' } diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 42b42e6ba7..16b957cc87 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -3,24 +3,25 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' + bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' afterEach(cleanup) @@ -28,6 +29,7 @@ const SID = 's1' as SessionId /** The chat-view locale seat: this package's namespace over the common fallback. */ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) // The read tool's real schema key is `file_path`; the top-level read samples // use it so the row exercises a production-shaped call. `web_fetch` (below) has @@ -256,7 +258,7 @@ describe('ReadRow keyed toolview', () => { describe('DetailsPanel Output section (read)', () => { function mount( - snapshot: ConversationSnapshot, + snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string, description?: Parameters[1], @@ -274,19 +276,23 @@ describe('DetailsPanel Output section (read)', () => { subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - t={t} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + t={chatT} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -303,17 +309,13 @@ describe('DetailsPanel Output section (read)', () => { ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' } diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index 40e557194c..d1b3d8a1e0 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -2,30 +2,33 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' type SearchRowProps = Parameters[0] afterEach(cleanup) const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) /** The rendered search card's kind attribute, so a render site cannot silently drop it. */ function searchKindOf(container: HTMLElement): string | null { @@ -369,7 +372,7 @@ describe('SearchRow keyed card', () => { }) describe('DetailsPanel Output section (search)', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -377,18 +380,22 @@ describe('DetailsPanel Output section (search)', () => { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -401,22 +408,18 @@ describe('DetailsPanel Output section (search)', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' } diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index c56eee83c4..52e7b8897e 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -2,28 +2,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' type BashRowProps = Parameters[0] const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) afterEach(cleanup) @@ -438,7 +441,7 @@ describe('BashRow terminal card', () => { }) describe('DetailsPanel Output section', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -452,40 +455,40 @@ describe('DetailsPanel Output section', () => { subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' } @@ -635,28 +638,33 @@ describe('DetailsPanel Output section', () => { const chat = createChatStore().create() const closeDetails = vi.fn() const snap = snapshot() + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) const view = render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }))} - useWorkspaces={bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }))} + useSessionPendingInteraction={bindSnapshotSelector(attention)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={closeDetails} - t={t} + t={chatT} />, ) fireEvent.click(view.getByRole('button', { name: '关闭详情' })) diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx index db720ed056..66739e7149 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -2,7 +2,8 @@ /** todo_write atomic Tool presentation and its plan-summary model. */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { TodoRow, todoToolview } from '../src/client/tool/toolviews/todo-row.tsx' diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 7f18800303..77fca2e7da 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' -import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ToolTreeProps } from '../src/client/contract/slots.ts' @@ -24,8 +25,8 @@ function props( selectedCallId?: string, description?: HostDescription, ): ToolTreeProps { - const snapshot = {} as ConversationSnapshot - const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] + const snapshot = {} as SessionSnapshot + const useSession = ((selector: (value: SessionSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) => options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot'] return { diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index c0332e9ac6..5d8b36e722 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,14 +1,38 @@ /** Test adapter for the production conversation.details.tool registration. */ import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEventEntry, SessionToolCallView } from '@deepseek-ai/dsh-api-session-controller/types' +import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' import type { - ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' + ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps, + DetailsToolOwnerProps, RunningToolCall, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' -/** Framework session-area seat used by direct DetailsPanel tests. */ -export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId) +type TrajectorySnapshot = Parameters[0]>[0] + +const emptyTrajectory: TrajectorySnapshot = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} + +/** Stable empty Trajectory source for DetailsPanel fixtures. */ +export const useEmptyTrajectory: DetailsSlotProps['useTrajectory'] = selector => selector(emptyTrajectory) + +function jsonFixture(value: unknown): JsonValue { + if (!isJsonValue(value)) throw new Error('tool event fixture must be lossless JSON') + return value as JsonValue +} + +function sessionCallView(view: NonNullable): SessionToolCallView { + if (view.card !== 'generic') return view + const { rawInput, ...wireView } = view + return rawInput === undefined ? wireView : { ...wireView, rawInput: jsonFixture(rawInput) } +} /** Build the canonical Chat slice consumed by Tool rows and details tests. */ export function toolChatSnapshot( @@ -49,6 +73,77 @@ export function toolChatSnapshot( } } +/** Build the Session event window that projects settled root Tool calls into Chat. */ +export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly SessionEventEntry[] { + const firstTime = nodes[0]?.callTime ?? nodes[0]?.time ?? 0 + const entries: SessionEventEntry[] = [ + { + event: { + seq: 1, + time: firstTime - 2, + type: 'turn/start', + data: { turn: 1 }, + }, + }, + { + event: { + seq: 2, + time: firstTime - 1, + type: 'step/start', + data: { turn: 1, step: 1 }, + }, + }, + ] + for (const [index, node] of nodes.entries()) { + if (node.call === null) throw new Error(`tool fixture "${node.callId}" requires its call event`) + const callSeq = 3 + index * 2 + const callEntry: SessionEventEntry = { + event: { + seq: callSeq, + time: node.callTime ?? node.time - 1, + type: 'tool/call', + data: { + turn: 1, + step: 1, + callId: node.callId, + name: node.call.name, + arguments: node.call.argsRaw, + }, + }, + ...(node.callView === null ? {} : { view: { for: 'call', view: sessionCallView(node.callView) } }), + } + entries.push(callEntry) + const resultEntry: SessionEventEntry = { + event: { + seq: callSeq + 1, + time: node.time, + type: 'tool/result', + data: jsonFixture({ + turn: 1, + step: 1, + message: { + id: `result-${node.callId}`, + role: 'user', + source: { kind: 'tool', callId: node.callId }, + content: [{ + type: 'tool-result', + toolCallId: node.callId, + content: node.content.map(block => ({ ...block })), + isError: node.isError, + }], + }, + ...(node.error === undefined ? {} : { error: node.error }), + ...(node.meta === undefined ? {} : { meta: node.meta }), + }), + surfaceOp: 'append', + }, + ...(node.resultView === null ? {} : { view: { for: 'result', view: node.resultView } }), + } + entries.push(resultEntry) + } + return entries +} + /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index d311453c3d..f0eab2d1d1 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index c14575b236..2d9c2eb2d0 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -2,14 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' -import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + apply as applyChat, inject as injectChat, type ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' -import { toolChatSnapshot } from './tool-details-render.client.tsx' +import { toolSessionEvents } from './tool-details-render.client.tsx' const SID = 's1' as SessionId @@ -55,23 +59,29 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { + runtime.ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // ui-theme's Appearance row binds a durable scope through these two. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('remote', { $on: () => () => {} }) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } - runtime.provide('layout', layout) + runtime.ctx.provide('layout', layout) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: async (path: string) => { + runtime.workspaces.calls.push({ method: 'openPath', args: [path] }) + }, + } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S' }, - snapshot: { nodes, chat: toolChatSnapshot(nodes) }, + events: toolSessionEvents(nodes), session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), @@ -79,6 +89,7 @@ async function bench(nodes: ToolResultNode[]) { }) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) return { runtime, slots: runtime.slots, layout } } @@ -198,17 +209,21 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { + runtime.ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // ui-theme's Appearance row binds a durable scope through these two. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('remote', { $on: () => () => {} }) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: vi.fn(async () => {}), + } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) @@ -232,6 +247,7 @@ describe('registrant declaration injection', () => { // Mounting the package declares the slot and activates the waiting entry. await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) expect(runtime.slots.entries('tool.call.toolview').map(e => e.options.key)) .toEqual(expect.arrayContaining(['bash', 'late'])) diff --git a/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx index 5fa250433c..e3c80fc5e4 100644 --- a/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx @@ -2,7 +2,7 @@ // atomic-view props. Generic slot-system duals live in ui-slots tests. import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { ToolCallViewProps } from '../src/client/contract/slots.ts' describe('toolview type negatives (compile-time; body never runs)', () => { diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index ca19c75ea6..1896fd8f79 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -2,32 +2,35 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' afterEach(cleanup) const SID = 's1' as SessionId const t = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) const SEARCH_ARGS = '{"query":"deepseek harness"}' const FETCH_ARGS = '{"url":"https://example.com/page"}' @@ -196,7 +199,7 @@ describe('chat row web body', () => { }) describe('DetailsPanel web Output section', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -204,18 +207,22 @@ describe('DetailsPanel web Output section', () => { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -228,22 +235,18 @@ describe('DetailsPanel web Output section', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } it('renders the search card at full source allowance', () => { diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index fe0f300211..56237975ff 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -7,14 +7,16 @@ import { type DisclosureRowProps, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import { shallowEqual } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkflowRunKey } from './locales.ts' import type { WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, } from './workflow-definition.ts' import css from './WorkflowRunPanel.module.css' -/** Navigation action injected from the plugin's own SessionRuntime access. */ +/** Navigation action injected from the plugin's own Session Controller access. */ export interface WorkflowRunInjected { readonly openSession: (id: SessionId) => void } diff --git a/packages/client/ui-workflow-run/src/client/index.ts b/packages/client/ui-workflow-run/src/client/index.ts index 09cc4059bf..3ad36218ac 100644 --- a/packages/client/ui-workflow-run/src/client/index.ts +++ b/packages/client/ui-workflow-run/src/client/index.ts @@ -1,8 +1,12 @@ /** Browser plugin for durable workflow-run Conversation Nodes. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx' import { en, NS, type WorkflowRunKey, zh } from './locales.ts' import { workflowRunDefinition } from './workflow-definition.ts' @@ -15,11 +19,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Required services for Definition, keyed renderer, navigation, and copy. */ -export const inject = ['conversationEvents', 'slots', 'sessions', 'locale'] +export const inject = ['uiConversation', 'slots', 'sessions', 'locale'] /** Register the workflow Definition, dictionary, and keyed Chat renderer. */ export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(workflowRunDefinition) + ctx.uiConversation.events.register(workflowRunDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries') ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', diff --git a/packages/client/ui-workflow-run/src/client/workflow-definition.ts b/packages/client/ui-workflow-run/src/client/workflow-definition.ts index 0e674bd530..7f1eba7708 100644 --- a/packages/client/ui-workflow-run/src/client/workflow-definition.ts +++ b/packages/client/ui-workflow-run/src/client/workflow-definition.ts @@ -1,7 +1,7 @@ import type { - ChatConversationViewNode, ConversationLocation, ConversationNodeContext, - ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, @@ -34,7 +34,7 @@ export interface WorkflowRunChatData { readonly phases: readonly WorkflowRunPhaseData[] } -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '@deepseek-ai/dsh-client-ui-chat/client' { interface ChatNodeDataMap { /** Durable top-level workflow run and all members that actually started. */ 'workflow-run': WorkflowRunChatData diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 248f932639..afa4be8aeb 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -3,14 +3,20 @@ import { Context, Service } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { - ConversationEventRegistry, ConversationNodeAssembler, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationNodeAssembler, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { - ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, - ConversationViewDefinition, SessionId, SessionListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationEventInput, ConversationMatch, ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { + chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, + stubSettingsScope, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' import { WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps, } from '../src/client/WorkflowRunPanel.tsx' @@ -29,6 +35,22 @@ const PARENT_ID = 'parent' as SessionId const CHILD_ID = 'child-1' as SessionId const SECOND_ID = 'child-2' as SessionId +type TrajectoryState = Parameters[0]>[0] + +const panelSession = sessionSnapshot(PARENT_ID) +const panelAttention = new Map() +const panelWorkspace = workspaceSnapshot() +const panelConversation = conversationSnapshot() +const panelChat = emptyChatSnapshot() +const panelTrajectory: TrajectoryState = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} + interface ChatSnapshot { readonly nodes: ReadonlyMap } @@ -63,7 +85,7 @@ const chatViewDefinition: ConversationViewDefinition selector(sessions), - useSession: (() => undefined) as WorkflowRunPanelProps['useSession'], + useSessionPendingInteraction: selector => selector(panelAttention), + useSession: selector => selector(panelSession), useProjection: () => undefined, + useConversation: selector => selector(panelConversation), + useChat: selector => selector(panelChat), + useTrajectory: selector => selector(panelTrajectory), useInput: () => { throw new Error('unused') }, - inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'], - useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'], + inputActions: { + setDraft: () => {}, + addImages: () => false, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }, + useWorkspaces: selector => selector(panelWorkspace), useTurnData: () => undefined, - selectedCallId: undefined, - cwd: undefined, openFile: () => {}, inspectCall: () => {}, forkAt: () => {}, @@ -845,8 +875,8 @@ describe('plugin lifecycle', () => { ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin(ConversationEventRegistry).await() await ctx.plugin(TestSessions).await() + const conversationEvents = new UiConversation(ctx, ctx.sessions as never).events ctx.slots.register({ name: 'root', children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } }, @@ -854,19 +884,19 @@ describe('plugin lifecycle', () => { await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) const entry = ctx.slots.entries('conversation.chat.node')[0]! const face = entry.inject?.() as unknown as WorkflowRunInjected face.openSession(CHILD_ID) expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID]) await fiber.dispose() - expect(ctx.conversationEvents.entries()).toEqual([]) + expect(conversationEvents.entries()).toEqual([]) expect(ctx.slots.entries('conversation.chat.node')).toEqual([]) const replacement = ctx.plugin({ inject: [...inject], apply }) await replacement.await() - expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) await replacement.dispose() }) diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts index 6b946869f8..197776cb6d 100644 --- a/packages/client/web/src/platform.ts +++ b/packages/client/web/src/platform.ts @@ -7,13 +7,13 @@ /** The module specifiers the shell shares into the frozen module table. */ export const PLATFORM_MODULES = [ 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis', + '@deepseek-ai/dsh-client-store', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-ui-primitives', ] as const /** Client-bundle specifiers whose factories the parser preloads before the shell starts. */ export const PRELOADED_CLIENT_EXTERNALS = [ - '@deepseek-ai/dsh-client-runtime/client', ] as const /** One platform module specifier (a seed-table key). */ diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index c95f350561..1f213641ca 100644 --- a/packages/client/web/src/seed.ts +++ b/packages/client/web/src/seed.ts @@ -11,6 +11,7 @@ import * as ReactJsxRuntime from 'react/jsx-runtime' import * as ReactDom from 'react-dom' import * as ReactDomClient from 'react-dom/client' import * as Cordis from '@deepseek-ai/cordis' +import * as ClientStore from '@deepseek-ai/dsh-client-store' import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import type { PlatformModule } from './platform.ts' @@ -29,6 +30,7 @@ export function getStaticModules(): Record { 'react-dom': ReactDom, 'react-dom/client': ReactDomClient, '@deepseek-ai/cordis': Cordis, + '@deepseek-ai/dsh-client-store': ClientStore, '@deepseek-ai/dsh-client-ui-slots': UiSlots, '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives, } satisfies Record diff --git a/packages/extensions/cordis-client-runner/src/client/guard.ts b/packages/extensions/cordis-client-runner/src/client/guard.ts index 831209f7a4..f3d9bf9d40 100644 --- a/packages/extensions/cordis-client-runner/src/client/guard.ts +++ b/packages/extensions/cordis-client-runner/src/client/guard.ts @@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis' import type { DynamicCordisPackage } from '@deepseek-ai/dsh-api-remotes/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' /** Facade verbs beyond declared services (host CTX_VERBS twin). */ diff --git a/packages/extensions/cordis-client-runner/src/client/index.ts b/packages/extensions/cordis-client-runner/src/client/index.ts index f54bfc2318..da8b9343ed 100644 --- a/packages/extensions/cordis-client-runner/src/client/index.ts +++ b/packages/extensions/cordis-client-runner/src/client/index.ts @@ -16,7 +16,7 @@ import type { DynamicCordisInventoryRow, } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' // The Client Remote assembly is the one place the two planes meet: it mounts the // `dynamicCordisRunner` namespace and re-exports its payload vocabulary, so this // package names what it sends without importing a Host package. diff --git a/packages/extensions/cordis-client-runner/src/client/providers.ts b/packages/extensions/cordis-client-runner/src/client/providers.ts index d140ec454a..ad38aaa91d 100644 --- a/packages/extensions/cordis-client-runner/src/client/providers.ts +++ b/packages/extensions/cordis-client-runner/src/client/providers.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import { queryEventApi, queryServiceApi } from './api-catalog.ts' import type { ClientCordisInspectProviderRegistration } from './inspect-registry.ts' diff --git a/packages/extensions/cordis-client-runner/src/client/runtime.ts b/packages/extensions/cordis-client-runner/src/client/runtime.ts index 8f5b356de7..f89d2220af 100644 --- a/packages/extensions/cordis-client-runner/src/client/runtime.ts +++ b/packages/extensions/cordis-client-runner/src/client/runtime.ts @@ -21,7 +21,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { DynamicCordisStyles, evaluateClientHalf, DYNAMIC_CLIENT_REDIRECTS } from './evaluator.ts' import type { DynamicCordisEvaluatedPlugin } from './evaluator.ts' import { dynamicCordisContext } from './guard.ts' diff --git a/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts b/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts index 5d81d8798b..b79ac7d5a9 100644 --- a/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts @@ -16,7 +16,7 @@ import type { CordisDynamicPluginRunId, DynamicCordisPackage, } from '@deepseek-ai/dsh-api-remotes/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { dynamicCordisContext } from '../src/client/guard.ts' import type { DynamicCordisSlotLedgerRow } from '../src/client/guard.ts' diff --git a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts index 6f5711e0d1..4144d97bd0 100644 --- a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts @@ -18,7 +18,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: resolves the `ctx.remote.$on` surface. import type {} from '@deepseek-ai/dsh-api-gateway/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import * as NodeHalf from '../src/index.ts' import * as Invariant from '../src/invariant.ts' import * as ClientHalf from '../src/client/index.ts' @@ -448,7 +448,9 @@ describe('invariant companion', () => { expect(Invariant.name).toBe('cordis-client-runner-invariant') // No relation to audit here: the owned one is browser-local runner state. // An event this plugin declares nothing about: the bridge must not route it here. - expect(() => { (ctx.emit as (type: string) => void)('unrelated/event') }).not.toThrow() + expect(() => { + Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) + }).not.toThrow() await fiber.dispose() }) }) diff --git a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts index ee271411ea..02eac311df 100644 --- a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts @@ -19,7 +19,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts' import { DynamicCordisPackageRunner } from '../src/client/runtime.ts' import type { DynamicCordisClientHalf, DynamicCordisRenderFailure } from '../src/client/runtime.ts' @@ -290,7 +290,7 @@ describe('failure stages', () => { await bench.runner.load(half({ code: 'return { apply: (ctx) => { ctx.on("t/ping", () => console.error("after load")) } }', })) - ;(bench.ctx.emit as (type: string) => void)('t/ping') + Reflect.apply(bench.ctx.emit.bind(bench.ctx), undefined, ['t/ping']) const mirrored = logged.mock.calls.filter(call => String(call[0]).includes('logged an error')) logged.mockRestore() expect(mirrored).toHaveLength(1) diff --git a/packages/extensions/ui-cordis/src/client/index.ts b/packages/extensions/ui-cordis/src/client/index.ts index a757f08f15..239a378b4d 100644 --- a/packages/extensions/ui-cordis/src/client/index.ts +++ b/packages/extensions/ui-cordis/src/client/index.ts @@ -1,10 +1,13 @@ /** Cordis dynamic-plugin cards, inventory panel, business-view host, and `@pluginId` source. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { InputTriggerService, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type {} from './events.ts' import { CordisActionRow } from './CordisActionRow.tsx' diff --git a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts index ec99bd2df2..2d335202ff 100644 --- a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts @@ -2,7 +2,7 @@ // call/result slice. import { describe, expect, it } from 'vitest' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { cordisActionCard, cordisDefineCard } from '../src/client/card-model.ts' const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return {}","host":"harness.handle(\'now\', () => Date.now())"}}' diff --git a/packages/session-query/session-log-export/src/client/Dialog.tsx b/packages/session-query/session-log-export/src/client/Dialog.tsx index c2e1b47528..dc51918c61 100644 --- a/packages/session-query/session-log-export/src/client/Dialog.tsx +++ b/packages/session-query/session-log-export/src/client/Dialog.tsx @@ -1,4 +1,5 @@ -import type { ObservableSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionLogDownloadState } from './controller.ts' diff --git a/packages/session-query/session-log-export/src/client/controller.ts b/packages/session-query/session-log-export/src/client/controller.ts index 4423d19f4e..f7f01cceb9 100644 --- a/packages/session-query/session-log-export/src/client/controller.ts +++ b/packages/session-query/session-log-export/src/client/controller.ts @@ -1,6 +1,7 @@ /** Browser download state shared by the Session Header button and `/export`. */ -import { createSnapshotStore, type SessionId, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Download phases presented by the shared modal. */ export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error' diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index b15b306d2c..97187311fa 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -1,9 +1,12 @@ /** Browser plugin owning Session export download state and its shared modal. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-commands/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { SessionLogDownloadController } from './controller.ts' import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' import { SessionLogDownloadHeaderAction } from './HeaderAction.tsx' diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index 7e54a4c773..1d669e298e 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' diff --git a/packages/session-query/session-log-export/tests/controller.client.spec.ts b/packages/session-query/session-log-export/tests/controller.client.spec.ts index 8b982a7474..a5fc95852f 100644 --- a/packages/session-query/session-log-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/controller.client.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { downloadUrl, SessionLogDownloadController, sessionLogZipFilename, } from '../src/client/controller.ts' diff --git a/packages/session-query/session-log-export/tests/dialog.client.spec.tsx b/packages/session-query/session-log-export/tests/dialog.client.spec.tsx index 6a70586ae2..fef9adbbdf 100644 --- a/packages/session-query/session-log-export/tests/dialog.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/dialog.client.spec.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionLogDownloadDialog } from '../src/client/Dialog.tsx' import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx' diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 775d892c6a..f428e3ada3 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx' diff --git a/packages/test-support/client-runtime/src/fixtures.ts b/packages/test-support/client-runtime/src/fixtures.ts index f8b0a43f77..dc0ff57744 100644 --- a/packages/test-support/client-runtime/src/fixtures.ts +++ b/packages/test-support/client-runtime/src/fixtures.ts @@ -1,10 +1,18 @@ -/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */ +/** Controller and UI-domain fixture shapes for the client test runtime. */ import type { - ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ISession, SessionSnapshot, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + EMPTY_CONVERSATION_SNAPSHOT, + type ConversationSnapshot, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + EMPTY_CHAT_SNAPSHOT, + type ChatSnapshot, +} from '@deepseek-ai/dsh-client-ui-chat/client' /** * Fixture overrides for the session behavior face: any subset of the @@ -24,6 +32,12 @@ export type SessionBehaviorOverrides = Partial & Record void | Promise) => Promise +/** Mutable top-level snapshot fields accepted by fixture update callbacks. */ +export type FixtureSnapshot = { -readonly [Key in keyof T]: T[Key] } + +/** Writable test representation of the immutable Session Controller snapshot. */ +export type SessionFixtureSnapshot = FixtureSnapshot + /** * Session fixture accepted by {@link TestSessions.add}: identity plus optional * snapshot/list-row overrides and the session behavior face the feature under @@ -32,33 +46,29 @@ export type Stabilizer = (fn: () => void | Promise) => Promise */ export interface SessionFixture { id: string - /** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */ - snapshot?: Partial> + /** Overrides merged over {@link sessionSnapshot}; Conversation data arrives through the event feed. */ + snapshot?: Partial> /** List-row overrides merged over the defaults derived from `id`. */ summary?: Partial> /** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */ session?: SessionBehaviorOverrides + /** Initial contiguous event window consumed by Conversation assembly. */ + events?: readonly SessionEventEntry[] + /** Whether the initial event window has an older page. */ + hasMore?: boolean } /** - * A complete quiescent conversation snapshot (open window, no traffic). + * A complete quiescent Session Controller snapshot. * @param sessionId - owning session id. * @returns the snapshot; spread fixture overrides on top. */ -export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { +export function sessionSnapshot(sessionId: SessionId): SessionSnapshot { return { sessionId, - views: EMPTY_CONVERSATION_VIEWS, - chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], - turnTimings: new Map(), - turnEnds: new Map(), - partial: null, - runningCalls: [], queue: [], running: false, subagent: null, - composerPhase: 'active', removed: false, openState: 'open', openError: null, @@ -67,22 +77,41 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot promptError: null, blank: false, lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, } } /** - * A ready workspace list with no workspaces (the shape WorkspaceRuntime - * projects after both baselines land). - * @returns the initial state of the test workspaces store. + * A target-neutral Conversation snapshot. + * @param overrides - target roster or activity overrides. + * @returns an immutable fixture value. */ -export function workspaceListState(): WorkspaceListState { +export function conversationSnapshot( + overrides: Partial = {}, +): ConversationSnapshot { + return { ...EMPTY_CONVERSATION_SNAPSHOT, ...overrides } +} + +/** + * A Chat target snapshot. + * @param overrides - Chat target overrides. + * @returns an immutable fixture value. + */ +export function chatSnapshot(overrides: Partial = {}): ChatSnapshot { + return { ...EMPTY_CHAT_SNAPSHOT, ...overrides } +} + +/** + * A ready Workspace Controller snapshot with no Workspace rows. + * @returns the initial state of the test Workspace source. + */ +export function workspaceSnapshot(): WorkspaceSnapshot { return { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, - recentWorkspaceId: undefined, } } diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index b13d87fd17..4c19296f65 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -1,6 +1,6 @@ /** * jsdom slot test runtime: a real small runtime — Cordis `Context`, the - * runtime `SlotRegistry`, and the UI renderer — assembled around + * renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around * test-owned session/workspace doubles, so feature specs exercise * declaration, registration, scope, store, inject, rendering, updates, and * disposal without hand-building the machinery per suite. @@ -22,11 +22,12 @@ import { act, render, within } from '@testing-library/react' import type { RenderResult } from '@testing-library/react' import type { queries } from '@testing-library/dom' import type { BoundFunctions } from '@testing-library/dom' -import { - ConversationEventRegistry, ConversationViewRegistry, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { bindSnapshotSelector as bindRendererSnapshotSelector } from '@deepseek-ai/dsh-client-ui-renderer/src/client/bind.ts' import { createSlotRenderer as createRenderer } from '@deepseek-ai/dsh-client-ui-renderer/src/client/scoped-slots.tsx' +import { + apply as applyUiSession, inject as uiSessionInject, +} from '@deepseek-ai/dsh-client-ui-session/client' import type { ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer, SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike, @@ -36,15 +37,19 @@ import { TestSessions } from './sessions.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' -export type { UseSession } from '@deepseek-ai/dsh-client-ui-renderer/client' +export type { UseSession } from '@deepseek-ai/dsh-client-ui-session/client' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' export { stubSettingsScope } from './settings-scope.ts' export type { StubSettingsScope } from './settings-scope.ts' export { TestWorkspaces } from './workspaces.ts' export { TestRemote } from './remote.ts' -export { conversationSnapshot, workspaceListState } from './fixtures.ts' -export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' +export { + chatSnapshot, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from './fixtures.ts' +export type { + FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer, +} from './fixtures.ts' export { makeTranslate } from './translate.ts' export { usePinnedBrowserLanguages } from './locale-env.ts' @@ -191,7 +196,7 @@ export class TestRoot { * batching or React act themselves. */ export class SlotTestRuntime { - /** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */ + /** The runtime's Cordis root for owner APIs and explicit test-only services. */ readonly ctx: Context /** The production SlotRegistry mounted on {@link SlotTestRuntime.ctx}. */ readonly slots: SlotRegistry @@ -214,6 +219,7 @@ export class SlotTestRuntime { private readonly ownerCell = new OwnerPropsCell() private readonly autoDeclared = new Set() private autoRootView: RenderResult | undefined + private readonly disposeWorkspaceSource: () => void private constructor(ctx: Context, slots: SlotRegistry) { this.ctx = ctx @@ -223,6 +229,7 @@ export class SlotTestRuntime { this.workspaces = new TestWorkspaces(this.stabilizer) ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) + this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } }) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() @@ -244,23 +251,9 @@ export class SlotTestRuntime { const ctx = new Context() const fiber = ctx.plugin(SlotRegistry) await fiber.await() - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - return new SlotTestRuntime(ctx, ctx.get('slots') as SlotRegistry) - } - - /** - * Provide an extra service the feature under test injects (e.g. a layout - * fake). Sugar over `ctx.provide`, typed against the Context declaration - * merge: for a declared service name the fake must be a subset of that - * service's outward face (Partial — supply only what the feature calls), - * so a production face change breaks the fake at compile time. Undeclared - * names stay unchecked (ad-hoc test services). - * @param name - service name. - * @param value - service implementation (test double). - */ - provide(name: K, value: K extends keyof Context ? Partial : unknown): void { - this.ctx.provide(name, value) + const runtime = new SlotTestRuntime(ctx, ctx.get('slots') as SlotRegistry) + await ctx.plugin({ inject: [...uiSessionInject], apply: applyUiSession }).await() + return runtime } /** @@ -293,6 +286,11 @@ export class SlotTestRuntime { return handle } + /** Release the default Workspace hook before mounting its production owner. */ + releaseWorkspaceSource(): void { + this.disposeWorkspaceSource() + } + /** * Render the root slot tree through the ctx-level entry (the shell's own * entry point): `ctx.slots.renderSlot('root', {})` under Testing Library. diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 44b3d35cf6..afb24fa287 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -1,41 +1,47 @@ -/** Test-owned sessions face: the SlotRegistry host contract over declarative fixtures. */ +/** Test-owned Session Controller faces over declarative fixtures. */ import type { Context } from '@deepseek-ai/cordis' import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' import { - createScope, scopeOf, SessionProvideChannel, SESSION_SEARCH_RESULT_LIMIT, -} from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + createScope, MutableSessionEventSource, scopeOf, SESSION_SEARCH_RESULT_LIMIT, +} from '@deepseek-ai/dsh-api-session-controller/client' import type { - AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, - SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, - SubagentAddress, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' -import { conversationSnapshot } from './fixtures.ts' -import type { SessionFixture, Stabilizer } from './fixtures.ts' + AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState, + SessionSearchResultItem, SessionSnapshot, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { sessionSnapshot } from './fixtures.ts' +import type { + SessionFixture, SessionFixtureSnapshot, Stabilizer, +} from './fixtures.ts' /** - * The fixture-backed session face: conversation reads delegate to the - * fixture's snapshot store; ISession verbs are fail-loud stubs unless the + * The fixture-backed session face: lifecycle reads delegate to the fixture's + * snapshot store; Session verbs are fail-loud stubs unless the * fixture supplies them (the runtime never fakes behavior a test did not * declare — an unstubbed call names itself instead of half-working). Extra * fixture methods are grafted verbatim for feature-side casts. */ export class FixtureSession implements SessionFace { + /** Mutable event source consumed only by Conversation assembly. */ + readonly eventSource = new MutableSessionEventSource() + /** - * The useProjection seat: identity-stable per-key faces over the fixture's - * projection values (set via {@link TestSessions.setProjection}). + * Identity-stable per-key faces over fixture-controlled projection values. */ readonly projections: ProjectionsFace & { set(key: string, value: unknown): void } /** * @param sessionId - host identity (branded view of the fixture id). - * @param store - conversation snapshot store (updateSnapshot writes it). + * @param store - Session Controller snapshot store. * @param overrides - fixture-declared behavior face, grafted over the stubs. */ constructor( readonly sessionId: SessionId, - private readonly store: SnapshotStore, + private readonly store: SnapshotStore, overrides: Record, ) { const values = new Map() @@ -66,8 +72,8 @@ export class FixtureSession implements SessionFace { Object.assign(this, overrides) } - /** @returns the fixture conversation snapshot (useSession read side). */ - getSnapshot(): ConversationSnapshot { + /** @returns the fixture Session Controller snapshot (useSession read side). */ + getSnapshot(): SessionSnapshot { return this.store.getSnapshot() } @@ -141,50 +147,33 @@ export class FixtureSession implements SessionFace { /** One live test session: fixture-derived stores plus its minted scope state. */ interface SessionRecord { summary: SessionSummary - snapshot: SnapshotStore + snapshot: SnapshotStore session: FixtureSession scope: AgentContext | undefined scopeFiber: { dispose(): Promise } | undefined - /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */ - provideInfo: SessionProvideInfo | undefined -} - -/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */ -export interface TestSessionBinding { - readonly sessionId: SessionId - readonly session: FixtureSession - readonly ctx: AgentContext + binding: SessionBinding | undefined } /** * Sessions test double behind the renderer host and feature injects: owns the - * list/current observable, the standard-props provide channel (the runtime's - * `useSession` contribution included), scope minting through the production - * `createScope`, and the session behavior face supplied per fixture. + * list/current observable, scope minting through the production `createScope`, + * stable Controller bindings, and the session behavior face supplied per + * fixture. `ui-session` owns standard-source materialization. * * Implements the same ISessions face features receive as `ctx.sessions`, so * a production face change breaks this double at compile time; the extra - * members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and - * the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface. + * members (add/updateSessionSnapshot/event-window drivers/setCurrent/remove/ + * behavior/calls/stubs) are bench-only surface. */ export class TestSessions implements ISessions { /** The useSessions standard feed (list rows + current selection). */ readonly list: SnapshotStore - /** - * Atomic current-session provide projection (production SessionRuntime - * mirror): selection changes and provider-roster changes publish through - * this one source — the member the SlotRegistry host face hands the - * renderer's SessionProvider. - */ - readonly currentProvideInfo: HostObservable private readonly records = new Map() - /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */ - private readonly channel: SessionProvideChannel /** Calls observed on the service-level face, newest last. */ readonly calls: { - method: 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents' - | 'clear' | 'search' | 'fork' + method: 'create' | 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents' + | 'clear' | 'refresh' | 'search' | 'fork' args: unknown[] }[] = [] @@ -193,6 +182,7 @@ export class TestSessions implements ISessions { /** Replaceable search behavior (see {@link TestSessions.stubSearch}). */ private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined + private createStub: ((opts: Parameters[0]) => Promise) | undefined /** * @param stabilize - the owning runtime's act wrapper. @@ -203,19 +193,6 @@ export class TestSessions implements ISessions { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - this.channel = new SessionProvideChannel({ - rebuildBundles: () => { - for (const record of this.records.values()) { - if (record.provideInfo !== undefined) { - record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record)) - } - } - }, - resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), - }) - this.currentProvideInfo = this.channel.currentProvideInfo - // The projection follows every current write, as in production. - this.list.subscribe(() => { this.channel.publishCurrent() }) } /** @@ -235,17 +212,21 @@ export class TestSessions implements ISessions { updatedAt: this.records.size + 1, ...fixture.summary, } - const snapshot = createSnapshotStore({ - ...conversationSnapshot(id), + const snapshot = createSnapshotStore({ + ...sessionSnapshot(id), ...fixture.snapshot, }) + const session = new FixtureSession(id, snapshot, fixture.session ?? {}) + if (fixture.events !== undefined || fixture.hasMore === true) { + session.eventSource.replace(fixture.events ?? [], fixture.hasMore ?? false) + } this.records.set(id, { summary, snapshot, - session: new FixtureSession(id, snapshot, fixture.session ?? {}), + session, scope: undefined, scopeFiber: undefined, - provideInfo: undefined, + binding: undefined, }) await this.stabilize(() => { this.list.update((draft) => { @@ -258,16 +239,55 @@ export class TestSessions implements ISessions { } /** - * Update a session's conversation snapshot through an immer draft (the - * live-stream stand-in: components subscribed via useSession re-render). + * Update Session Controller lifecycle state through an immer draft. * @param id - session id. * @param mutate - draft mutator. */ - async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise { + async updateSessionSnapshot( + id: string, + mutate: (draft: SessionFixtureSnapshot) => void, + ): Promise { const record = this.require(id) await this.stabilize(() => { record.snapshot.update(mutate) }) } + /** + * Replace a Session's complete contiguous event window. + * @param id - Session identity. + * @param entries - complete event window. + * @param hasMore - whether older history remains. + */ + async replaceEvents( + id: string, + entries: readonly SessionEventEntry[], + hasMore = false, + ): Promise { + await this.stabilize(() => { this.require(id).session.eventSource.replace(entries, hasMore) }) + } + + /** + * Prepend one older contiguous event page. + * @param id - Session identity. + * @param entries - older entries. + * @param hasMore - whether another older page remains. + */ + async prependEvents( + id: string, + entries: readonly SessionEventEntry[], + hasMore = false, + ): Promise { + await this.stabilize(() => { this.require(id).session.eventSource.prepend(entries, hasMore) }) + } + + /** + * Append one live event to a Session's contiguous window. + * @param id - Session identity. + * @param entry - live event entry. + */ + async appendEvent(id: string, entry: SessionEventEntry): Promise { + await this.stabilize(() => { this.require(id).session.eventSource.append(entry) }) + } + /** * Update a session's list row (the wire-echo stand-in: title settles, * running flips — components subscribed via useSessions re-render). @@ -296,7 +316,7 @@ export class TestSessions implements ISessions { /** * Remove a session: list row, scope fiber, and per-session store instances * (with persisted state) die together — the same single lifecycle axis the - * production SessionRuntime drives on session death, minus staging. + * production Client Sessions service drives on session death, minus staging. * @param id - session id. */ async remove(id: string): Promise { @@ -310,43 +330,9 @@ export class TestSessions implements ISessions { if (draft.current === id) draft.current = undefined }) if (record.scopeFiber !== undefined) await record.scopeFiber.dispose() - this.rootCtx.get('slots')?.pruneStoreScope(id) }) } - /** - * Register a per-session standard-props provider (production `provide` - * contract: hooks become `use` selector hooks on the render side, - * props spread verbatim; duplicate names fail loud at materialization). - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider. - */ - provide(descriptor: SessionProvideDescriptor): () => void { - return this.channel.provide(descriptor) - } - - /** - * Resolve the definite per-session standard-props bundle (host face member). - * @param id - session id. - * @returns the identity-stable bundle, or undefined for unknown sessions. - */ - provideInfo(id: string): SessionProvideInfo | undefined { - const record = this.records.get(id as SessionId) - if (record === undefined) return undefined - record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record)) - return record.provideInfo - } - - /** - * Resolve the current-session-optional standard kit (host face member): - * unknown or absent ids return the static no-session projection. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. - */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo - } - /** * Resolve (mint on first touch) the session-scoped Cordis context through * the production `createScope`, so real `scopeOf`/scope-addressed services @@ -370,10 +356,11 @@ export class TestSessions implements ISessions { * @param id - session id. * @returns sessionId + behavior face + scoped ctx, or undefined when unknown. */ - binding(id: string): TestSessionBinding | undefined { + binding(id: string): SessionBinding | undefined { const record = this.records.get(id as SessionId) if (record === undefined) return undefined - return this.bindingOf(id as SessionId, record) + record.binding ??= this.bindingOf(id as SessionId, record) + return record.binding } /** @@ -397,6 +384,25 @@ export class TestSessions implements ISessions { return this.records.get(id)?.session } + /** + * Install Session creation behavior for navigation tests. + * @param impl - implementation that must return an already-added fixture id. + */ + stubCreate(impl: (opts: Parameters[0]) => Promise): void { + this.createStub = impl + } + + /** Create through the installed test behavior and require an addressable binding. */ + async create(opts?: Parameters[0]): Promise { + this.calls.push({ method: 'create', args: [opts] }) + if (this.createStub === undefined) { + throw new Error('test sessions: create is not stubbed — call stubCreate() first') + } + const id = await this.createStub(opts) + this.require(id) + return id + } + /** * Service-level selection call (recorded, then applied to the list store * synchronously — inject callbacks call this outside any act window; the @@ -456,6 +462,12 @@ export class TestSessions implements ISessions { }) } + /** Record a list refresh; fixture callers publish list state explicitly. */ + refresh(): Promise { + this.calls.push({ method: 'refresh', args: [] }) + return Promise.resolve() + } + /** * Replace the sidebar-search result page (the call is still recorded). * @param impl - hits for a query, as the Host would rank them. @@ -492,7 +504,7 @@ export class TestSessions implements ISessions { * The session face of a fixture (typed view for assertions; fixture * behavior methods are grafted onto it). * @param id - session id. - * @returns the FixtureSession the binding and provide channel carry. + * @returns the FixtureSession carried by the Controller binding. */ behavior(id: string): FixtureSession { return this.require(id).session @@ -505,16 +517,22 @@ export class TestSessions implements ISessions { await record.scopeFiber.dispose() record.scope = undefined record.scopeFiber = undefined + record.binding = undefined } } } - private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding { + private bindingOf(id: SessionId, record: SessionRecord): SessionBinding { const ctx = this.scope(id) /* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope * always resolves; kept so a future caller cannot mint a ctx-less binding. */ if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`) - return { sessionId: id, session: record.session, ctx } + return { + sessionId: id, + session: record.session, + eventSource: record.session.eventSource, + ctx, + } } private require(id: string): SessionRecord { @@ -522,4 +540,5 @@ export class TestSessions implements ISessions { if (record === undefined) throw new Error(`test session "${id}" is not added`) return record } + } diff --git a/packages/test-support/client-runtime/src/settings-scope.ts b/packages/test-support/client-runtime/src/settings-scope.ts index 377ed84210..86566bb33a 100644 --- a/packages/test-support/client-runtime/src/settings-scope.ts +++ b/packages/test-support/client-runtime/src/settings-scope.ts @@ -1,6 +1,8 @@ /** Test double for the client settings-scope seam. */ import { vi } from 'vitest' -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { + SettingsScope, SettingsScopeSnapshot, +} from '@deepseek-ai/dsh-client-ui-settings/client' /** Handle over one stubbed scope: the scope, its write spy, and publication controls. */ export interface StubSettingsScope { diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 4f6b2122cb..60e072af2a 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -1,10 +1,25 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' -import { workspaceListState } from './fixtures.ts' -import type { Stabilizer } from './fixtures.ts' + IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, +} from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { workspaceSnapshot } from './fixtures.ts' +import type { FixtureSnapshot, Stabilizer } from './fixtures.ts' + +/** Writable test representation of the immutable Workspace Controller snapshot. */ +type WorkspaceFixtureSnapshot = FixtureSnapshot + +/** Callable command names on the production Workspace Controller face. */ +type WorkspaceAction = { + [Key in keyof IWorkspaces]: IWorkspaces[Key] extends (...args: never[]) => unknown ? Key : never +}[keyof IWorkspaces] + +/** Test replacement retaining one Controller command's parameters and result. */ +type WorkspaceStub = ( + ...args: Parameters +) => ReturnType /** * Workspaces test double. Implements the same IWorkspaces face features @@ -15,59 +30,36 @@ import type { Stabilizer } from './fixtures.ts' */ export class TestWorkspaces implements IWorkspaces { /** The useWorkspaces standard feed. */ - readonly list: SnapshotStore + readonly list: SnapshotStore /** Calls observed on the action face, newest last. */ readonly calls: { method: string; args: unknown[] }[] = [] /** Replaceable action seat: feature tests may stub richer behavior. */ - private readonly stubs = new Map unknown>() + private readonly stubs = new Map unknown>() /** * @param stabilize - the owning runtime's act wrapper. */ constructor(private readonly stabilize: Stabilizer) { - this.list = createSnapshotStore(workspaceListState()) + this.list = createSnapshotStore({ ...workspaceSnapshot() }) } /** * Update the workspace list state through an immer draft. * @param mutate - draft mutator. */ - async update(mutate: (draft: WorkspaceListState) => void): Promise { + async update(mutate: (draft: WorkspaceFixtureSnapshot) => void): Promise { await this.stabilize(() => { this.list.update(mutate) }) } /** * Replace an action's behavior (the recorded call is still appended first). - * @param method - action name (e.g. 'connectWorkspace'). + * @param method - Controller action name (e.g. 'create'). * @param impl - replacement behavior. */ - stub(method: string, impl: (...args: unknown[]) => unknown): void { - this.stubs.set(method, impl) - } - - /** - * Connect a workspace to its reusable/new blank session (recorded). The - * default resolves the workspace id back as the session id; stub for - * cross-session flows. - * @param workspaceId - target workspace. - * @returns the connected session id. - */ - async connectWorkspace(workspaceId: WorkspaceId): Promise { - this.calls.push({ method: 'connectWorkspace', args: [workspaceId] }) - const stub = this.stubs.get('connectWorkspace') - if (stub !== undefined) return await (stub(workspaceId) as Promise) - return `session-of-${workspaceId}` as SessionId - } - - /** - * New-session flow (recorded; stubbed behavior runs when installed). - * @param workspaceId - optional explicit workspace target. - */ - startSession(workspaceId?: WorkspaceId): void { - this.calls.push({ method: 'startSession', args: [workspaceId] }) - this.stubs.get('startSession')?.(workspaceId) + stub(method: Key, impl: WorkspaceStub): void { + this.stubs.set(method, impl as (...args: unknown[]) => unknown) } /** @@ -88,68 +80,6 @@ export class TestWorkspaces implements IWorkspaces { } as unknown as WorkspaceView } - /** - * Open a path with the host OS default application (recorded; default no-op). - * @param path - host-resolvable path. - */ - async openPath(path: string): Promise { - this.calls.push({ method: 'openPath', args: [path] }) - await (this.stubs.get('openPath')?.(path) as Promise | undefined) - } - - /** - * Directory picker (recorded). The default cancels (null); stub to select. - * @returns the picked path, or null. - */ - async pickDirectory(): Promise { - this.calls.push({ method: 'pickDirectory', args: [] }) - const stub = this.stubs.get('pickDirectory') - if (stub !== undefined) return await (stub() as Promise) - return null - } - - /** - * Browse listing (recorded). The default serves an empty home level; stub - * to shape a tree. - * @param path - absolute directory to list; absent lists the home level. - * @returns the level's listing. - */ - async listDirectory(path?: string, signal?: AbortSignal): Promise { - // The signal is recorded and forwarded like the production face passes - // it to the wire, so cancellation integration tests can observe or - // reject on a superseded scan. - this.calls.push({ method: 'listDirectory', args: [path, signal] }) - const stub = this.stubs.get('listDirectory') - if (stub !== undefined) return await (stub(path, signal) as Promise) - // The chain runs root-to-target inclusive, per the DirectoryListing - // contract — a bare root crumb would mislabel the level in browsers - // driven by this double. - return { - path: '/home/test', - home: '/home/test', - crumbs: [ - { name: '/', path: '/', hidden: false }, - { name: 'home', path: '/home', hidden: false }, - { name: 'test', path: '/home/test', hidden: false }, - ], - entries: [], - truncated: false, - } - } - - /** - * Browse child creation (recorded). The default joins parent and name. - * @param path - absolute existing parent directory. - * @param name - single path segment. - * @returns the created directory's absolute path. - */ - async createDirectory(path: string, name: string): Promise { - this.calls.push({ method: 'createDirectory', args: [path, name] }) - const stub = this.stubs.get('createDirectory') - if (stub !== undefined) return await (stub(path, name) as Promise) - return `${path}/${name}` - } - /** * Rename a Workspace (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx new file mode 100644 index 0000000000..58d639864d --- /dev/null +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' +import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { + bindSnapshotSelector, + chatSnapshot, + conversationSnapshot, + SlotTestRuntime, + usePinnedBrowserLanguages, +} from '../src/index.ts' + +const originalLanguages = [...navigator.languages] +const originalLanguage = navigator.language + +usePinnedBrowserLanguages('zh-CN', 'en-US') +afterEach(cleanup) +afterAll(() => { + expect(navigator.languages).toEqual(originalLanguages) + expect(navigator.language).toBe(originalLanguage) +}) + +function entry(seq: number): SessionEventEntry { + return { + event: { + type: 'fixture/event', + seq, + time: seq, + data: { seq }, + ignorable: true, + }, + } +} + +describe('fixture helpers', () => { + it('builds independent Conversation and Chat snapshots with optional overrides', () => { + const conversation = conversationSnapshot() + expect(conversation).toEqual(EMPTY_CONVERSATION_SNAPSHOT) + expect(conversation).not.toBe(EMPTY_CONVERSATION_SNAPSHOT) + const activeTargets = new Set(['chat']) + expect(conversationSnapshot({ activeTargets }).activeTargets).toBe(activeTargets) + + const chat = chatSnapshot() + expect(chat).toEqual(EMPTY_CHAT_SNAPSHOT) + expect(chat).not.toBe(EMPTY_CHAT_SNAPSHOT) + const order = ['node-1'] + expect(chatSnapshot({ order }).order).toBe(order) + }) + + it('binds an observable snapshot through the production selector hook', () => { + const source = createSnapshotStore({ value: 1 }) + const useValue = bindSnapshotSelector(source) + const view = renderHook(() => useValue(snapshot => snapshot.value)) + expect(view.result.current).toBe(1) + + act(() => { source.update((draft) => { draft.value = 2 }) }) + expect(view.result.current).toBe(2) + }) + + it('pins both browser language fields for the calling suite', () => { + expect(navigator.languages).toEqual(['zh-CN', 'en-US']) + expect(navigator.language).toBe('zh-CN') + }) +}) + +describe('Session fixture lifecycle', () => { + it('initializes and drives complete event windows through replace, prepend, and append', async () => { + const runtime = await SlotTestRuntime.create() + const first = entry(1) + const older = entry(0) + const live = entry(2) + + await runtime.sessions.add({ id: 'events', events: [first] }, { current: false }) + expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({ + entries: [first], + hasMore: false, + change: { kind: 'replace', entries: [first] }, + }) + + await runtime.sessions.add({ id: 'has-more', hasMore: true }, { current: false }) + expect(runtime.sessions.behavior('has-more').eventSource.getSnapshot()).toMatchObject({ + entries: [], + hasMore: true, + }) + + await runtime.sessions.replaceEvents('events', [first]) + await runtime.sessions.prependEvents('events', [older]) + await runtime.sessions.appendEvent('events', live) + expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({ + entries: [older, first, live], + hasMore: false, + change: { kind: 'append', entries: [live] }, + }) + await runtime.dispose() + }) + + it('requires an explicit create stub and records successful create and refresh calls', async () => { + const runtime = await SlotTestRuntime.create() + await expect(runtime.sessions.create()).rejects.toThrow(/create is not stubbed/) + await runtime.sessions.add({ id: 'created' }, { current: false }) + const create = vi.fn(() => Promise.resolve('created' as SessionId)) + runtime.sessions.stubCreate(create) + + await expect(runtime.sessions.create({ cwd: '/workspace' })).resolves.toBe('created') + await expect(runtime.sessions.refresh()).resolves.toBeUndefined() + expect(create).toHaveBeenCalledWith({ cwd: '/workspace' }) + expect(runtime.sessions.calls.slice(-2)).toEqual([ + { method: 'create', args: [{ cwd: '/workspace' }] }, + { method: 'refresh', args: [] }, + ]) + await runtime.dispose() + }) + + it('disposes a scope without materializing a binding', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.sessions.add({ id: 'scope-only' }, { current: false }) + const scope = runtime.sessions.scope('scope-only') + expect(scope).toBeDefined() + const release = vi.fn() + scope?.effect(() => release, 'fixture scope release') + + runtime.releaseWorkspaceSource() + await runtime.dispose() + expect(release).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index 697cd197ad..a888168994 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -9,8 +9,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { stubSettingsScope } from '../src/settings-scope.ts' import { cleanup } from '@testing-library/react' -import { defineStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import { defineStore } from '@deepseek-ai/dsh-client-store' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -93,7 +94,7 @@ describe('sessions', () => { await runtime.sessions.add({ id: 's1' }) expect(view.container.textContent).toContain('chat:s1:false') - await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true }) + await runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true }) expect(view.container.textContent).toContain('chat:s1:true') await runtime.sessions.add({ id: 's2' }) // becomes current by default @@ -114,7 +115,7 @@ describe('sessions', () => { expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2']) await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/) await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/) - await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/) + await expect(runtime.sessions.updateSessionSnapshot('ghost', () => {})).rejects.toThrow(/not added/) await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/) expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/) await runtime.dispose() @@ -125,7 +126,6 @@ describe('sessions', () => { const prompt = vi.fn() await runtime.sessions.add({ id: 's1', session: { prompt } }) - expect(runtime.sessions.provideInfo('ghost')).toBeUndefined() expect(runtime.sessions.scope('ghost')).toBeUndefined() expect(runtime.sessions.binding('ghost')).toBeUndefined() @@ -140,69 +140,17 @@ describe('sessions', () => { const binding = runtime.sessions.binding('s1')! expect(binding.sessionId).toBe('s1') expect(binding.ctx).toBe(scope) - ;(binding.session as { prompt: () => void }).prompt() + await binding.session.prompt([], 'queue') expect(prompt).toHaveBeenCalledOnce() expect(runtime.sessions.behavior('s1')).toBe(binding.session) - // The binding's session doubles as the conversation observable face. - expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1') + expect(binding.session.getSnapshot().sessionId).toBe('s1') // A scoped service resolves through the scope ctx (scope-addressed pattern). - runtime.provide('probe', { hello: 'world' }) + runtime.ctx.provide('probe', { hello: 'world' }) expect(scope.get('probe')).toEqual({ hello: 'world' }) await runtime.dispose() }) - it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => { - const runtime = await runtimeWithFrame() - await runtime.sessions.add({ id: 's1' }) - - const info = runtime.sessions.provideInfo('s1')! - expect(info.sessionId).toBe('s1') - expect(info.hooks['session']).toBeDefined() // the built-in useSession source - expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable - - // A feature provider (the ui-conversation input pattern): declared names - // materialize per session and land in the no-session roster as undefined. - const off = runtime.sessions.provide({ - hooks: ['probe'], - props: ['probeActions'], - resolve: binding => ({ - hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } }, - props: { probeActions: { poke: () => {} } }, - }), - }) - const enriched = runtime.sessions.provideInfo('s1')! - expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1') - expect(enriched.props['probeActions']).toBeDefined() - const maybe = runtime.sessions.maybeProvideInfo(undefined) - expect(maybe.sessionId).toBeUndefined() - expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe']) - expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1')) - expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined() - - // Misdeclared providers fail loud AT REGISTRATION (the production - // channel rebuilds live bundles eagerly and rolls the roster back): - // missing hook, missing prop, duplicate hook, duplicate prop. - expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) })) - .toThrow(/missing hook "void"/) - expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) })) - .toThrow(/missing prop "void"/) - expect(() => runtime.sessions.provide({ - hooks: ['session'], - resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }), - })).toThrow(/duplicate hook "session"/) - const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) }) - expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) })) - .toThrow(/duplicate prop "twice"/) - propA() - // The rejected registrations rolled back: the roster still materializes. - expect(runtime.sessions.provideInfo('s1')).toBeDefined() - off() - off() // disposer is idempotent - expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session']) - await runtime.dispose() - }) - it('records service-face calls and retains catalog addresses only for addressed selection', async () => { const runtime = await runtimeWithFrame() await runtime.sessions.add({ id: 's1' }) @@ -328,7 +276,7 @@ describe('stores', () => { await runtime.sessions.remove('s1') expect(localStorage.getItem('trt.store.s1')).toBeNull() expect(runtime.sessions.list.getSnapshot().ids).toEqual([]) - expect(runtime.sessions.provideInfo('s1')).toBeUndefined() + expect(runtime.sessions.binding('s1')).toBeUndefined() await runtime.sessions.add({ id: 's1' }) const reborn = runtime.storeOf('trt.chat', 's1') @@ -352,7 +300,7 @@ describe('stores', () => { }) describe('workspaces', () => { - it('feeds useWorkspaces and records/stubs intent actions', async () => { + it('feeds the renderer root source from the Workspace Controller snapshot', async () => { const runtime = await runtimeWithFrame() runtime.slots.register( { name: 'trt.panel' }, @@ -363,43 +311,6 @@ describe('workspaces', () => { await runtime.workspaces.update((draft) => { draft.phase = 'pending' }) expect(view.container.textContent).toContain('ws:pending') - - runtime.workspaces.startSession('w1' as WorkspaceId) - await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2') - expect(runtime.workspaces.calls).toEqual([ - { method: 'startSession', args: ['w1'] }, - { method: 'connectWorkspace', args: ['w2'] }, - ]) - const stub = vi.fn(() => Promise.resolve('other' as never)) - runtime.workspaces.stub('connectWorkspace', stub) - await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other') - expect(stub).toHaveBeenCalledOnce() - await runtime.dispose() - }) - - it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => { - const runtime = await runtimeWithFrame() - // Defaults: an empty home level and parent/name joining. - await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) - await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) - await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') - // The recorded signal seat mirrors the production face (undefined here; - // cancellation tests pass and observe a real one). - expect(runtime.workspaces.calls).toEqual([ - { method: 'listDirectory', args: [undefined, undefined] }, - { method: 'listDirectory', args: ['/home/test', undefined] }, - { method: 'createDirectory', args: ['/home/test', 'fresh'] }, - ]) - // Stubs replace the defaults like every sibling method. - const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } - const listStub = vi.fn(() => Promise.resolve(listing as never)) - runtime.workspaces.stub('listDirectory', listStub) - runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never))) - const scan = new AbortController() - await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing) - // The stub receives the signal too, like the production face gives the wire. - expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal) - await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made') await runtime.dispose() }) }) @@ -407,7 +318,7 @@ describe('workspaces', () => { describe('feature mount and disposal', () => { it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => { const runtime = await runtimeWithFrame() - runtime.provide('layout', { openDetails: vi.fn() }) + runtime.ctx.provide('layout', { openDetails: vi.fn() }) const feature = await runtime.mount({ inject: ['slots', 'layout'], apply: (ctx: typeof runtime.ctx) => { @@ -530,9 +441,15 @@ describe('fixture session face', () => { await runtime.dispose() }) - it('projections faces are identity-stable per key, read absent, and notify on set', async () => { - const runtime = await SlotTestRuntime.create() + it('projects controller values through the real ui-session and renderer path', async () => { + const runtime = await runtimeWithFrame() + runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => ( + todos:{props.useProjection('todos', value => value?.length ?? 0)} + )) + const view = runtime.renderRoot() await runtime.sessions.add({ id: 's1' }) + expect(view.container.textContent).toContain('todos:0') + const session = runtime.sessions.behavior('s1') const face = session.projections.faceOf('todos') expect(session.projections.faceOf('todos')).toBe(face) @@ -540,27 +457,16 @@ describe('fixture session face', () => { const seen: unknown[] = [] const off = face.subscribe(() => { seen.push(face.getSnapshot()) }) session.projections.set('todos', [1, 2]) + await runtime.flush() expect(seen).toEqual([[1, 2]]) + expect(view.container.textContent).toContain('todos:2') off() session.projections.set('todos', [3]) + await runtime.flush() expect(seen).toEqual([[1, 2]]) // unsubscribed + expect(view.container.textContent).toContain('todos:1') // A never-subscribed key sets without listeners (the empty-notify arm). session.projections.set('untouched', 1) - // The provide bundle hands the same store to the render side. - const info = runtime.sessions.provideInfo('s1')! - expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3]) - // A roster change rebuilds the ALREADY-materialized bundle eagerly - // (production channel semantics: mounted entries must see the provider) - // and skips never-materialized records (they pick the roster up lazily). - await runtime.sessions.add({ id: 's-lazy' }, { current: false }) - const offProbe = runtime.sessions.provide({ - hooks: ['probe2'], - resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }), - }) - const rebuilt = runtime.sessions.provideInfo('s1')! - expect(rebuilt).not.toBe(info) - expect(rebuilt.hooks['probe2']).toBeDefined() - offProbe() await runtime.dispose() }) }) @@ -573,11 +479,9 @@ describe('workspaces action face', () => { expect(created.title).toBe('/tmp/alpha') const registered = await ws.create({ path: '/tmp/beta' }) expect(registered.path).toBe('/tmp/beta') - await expect(ws.pickDirectory()).resolves.toBeNull() const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed') expect(renamed.title).toBe('Renamed') await ws.delete('w1' as WorkspaceId) - await ws.openPath('/proj/file.ts') await ws.insertBefore('w1' as WorkspaceId, 'w2' as WorkspaceId) const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) @@ -586,22 +490,18 @@ describe('workspaces action face', () => { await ws.archiveSession('s1' as SessionId) expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertBefore', 'insertSessionBefore', 'archiveSession']) + ['create', 'create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) - ws.stub('pickDirectory', () => Promise.resolve('/picked')) ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never)) ws.stub('delete', () => Promise.resolve()) - ws.stub('openPath', () => Promise.resolve()) const insertBefore = vi.fn(() => Promise.resolve()) ws.stub('insertBefore', insertBefore) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ path: '/y' })).title).toBe('X') - await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) - await ws.openPath('/other') await ws.insertBefore('w2' as WorkspaceId) expect(insertBefore).toHaveBeenCalledWith('w2', undefined) expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) From 3a23185edba1e779792ebad22940677c03d5ce22 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:20:23 +0800 Subject: [PATCH 195/248] chore(client): align split package graph --- apps/web/package.json | 1 + apps/web/tsconfig.json | 3 + knip.json | 15 + packages/bundle/web-app/package.json | 4 +- packages/client/locale/package.json | 7 +- packages/client/locale/tsconfig.json | 5 +- packages/client/ui-agent-preset/package.json | 7 +- packages/client/ui-agent-preset/tsconfig.json | 3 + .../client/ui-brand-official/package.json | 6 +- .../client/ui-brand-official/tsconfig.json | 2 +- packages/client/ui-commands/package.json | 16 +- packages/client/ui-commands/tsconfig.json | 15 +- packages/client/ui-conversation/package.json | 5 +- packages/client/ui-deliverables/package.json | 17 +- packages/client/ui-deliverables/tsconfig.json | 16 +- .../ui-directory-picker-browse/package.json | 12 +- .../ui-directory-picker-browse/tsconfig.json | 5 +- .../ui-directory-picker-native/package.json | 6 +- .../ui-directory-picker-native/tsconfig.json | 2 +- packages/client/ui-goal/package.json | 21 +- packages/client/ui-goal/tsconfig.json | 14 +- packages/client/ui-input-trigger/package.json | 26 +- .../client/ui-input-trigger/tsconfig.json | 17 +- packages/client/ui-jobs/package.json | 13 +- packages/client/ui-jobs/tsconfig.json | 12 +- packages/client/ui-layout/package.json | 10 +- packages/client/ui-layout/tsconfig.json | 8 +- .../client/ui-message-feedback/package.json | 18 +- .../client/ui-message-feedback/tsconfig.json | 11 +- .../client/ui-model-selection/package.json | 18 +- .../client/ui-model-selection/tsconfig.json | 11 +- .../client/ui-permission-presets/package.json | 18 +- .../ui-permission-presets/tsconfig.json | 11 +- packages/client/ui-reference/package.json | 3 - packages/client/ui-reference/tsconfig.json | 3 - packages/client/ui-session/package.json | 6 - packages/client/ui-session/tsconfig.json | 3 - .../client/ui-settings-general/package.json | 12 +- .../client/ui-settings-general/tsconfig.json | 8 +- .../client/ui-settings-models/package.json | 10 +- .../client/ui-settings-models/tsconfig.json | 5 +- .../ui-settings-plugin-inventory/package.json | 9 +- .../tsconfig.json | 6 +- .../client/ui-settings-plugins/package.json | 10 +- .../client/ui-settings-plugins/tsconfig.json | 5 +- packages/client/ui-settings/package.json | 4 +- packages/client/ui-settings/tsconfig.json | 5 +- packages/client/ui-sidebar/package.json | 3 + packages/client/ui-skill/package.json | 14 +- packages/client/ui-skill/tsconfig.json | 11 +- packages/client/ui-subagent/package.json | 21 +- packages/client/ui-subagent/tsconfig.json | 21 +- packages/client/ui-theme/package.json | 7 +- packages/client/ui-theme/tsconfig.json | 5 +- packages/client/ui-tool/package.json | 19 +- packages/client/ui-tool/tsconfig.json | 16 +- packages/client/ui-workflow-run/package.json | 18 +- packages/client/ui-workflow-run/tsconfig.json | 14 +- packages/client/web/package.json | 1 + packages/client/web/tsconfig.json | 3 + .../cordis-client-runner/package.json | 6 +- .../cordis-client-runner/tsconfig.json | 2 +- packages/extensions/ui-cordis/package.json | 10 +- packages/extensions/ui-cordis/tsconfig.json | 8 +- .../session-log-export/package.json | 12 +- .../session-log-export/tsconfig.json | 5 +- .../test-support/client-runtime/package.json | 28 +- .../test-support/client-runtime/tsconfig.json | 32 +- pnpm-lock.yaml | 744 +++++++++++++----- scripts/client-bundle-purity.spec.ts | 12 +- scripts/gen-cordis-catalog.ts | 13 +- scripts/rescope-vendor.ts | 6 +- scripts/verify-client-packages.spec.ts | 26 +- .../verify-package-readme-model-experience.ts | 5 +- tsconfig.base.json | 12 +- tsconfig.client.json | 14 +- vitest.config.ts | 18 +- 77 files changed, 1142 insertions(+), 408 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 5bdfe8ebd7..d1f70bedfc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web": "workspace:^", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 20d8887131..386615f059 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -104,6 +104,9 @@ "tests/workflow-run.e2e.ts" ], "references": [ + { + "path": "../../packages/client/store" + }, { "path": "../../packages/client/web" }, diff --git a/knip.json b/knip.json index c12d696c2a..9e0ad83da8 100644 --- a/knip.json +++ b/knip.json @@ -122,6 +122,20 @@ "zod" ] }, + "packages/client/ui-approval": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.tsx" + ] + }, + "packages/client/ui-subagent": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-input-trigger" + ] + }, "packages/client/ui-primitives": { "entry": [ "tests/**/*.spec.tsx" @@ -626,6 +640,7 @@ "tests/**/*.ts" ], "ignoreDependencies": [ + "@deepseek-ai/dsh-client-store", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-slots", "@types/react", diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 2c855b12ce..2f26a9daed 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -52,11 +52,12 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-approval": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", @@ -70,6 +71,7 @@ "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 8ab9691fc9..c9748afaba 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -33,7 +33,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" ], @@ -46,7 +46,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" @@ -54,9 +54,10 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8f2ac29049..f2dd8c6f0c 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -9,7 +9,10 @@ ], "references": [ { - "path": "../runtime" + "path": "../store" + }, + { + "path": "../ui-renderer" }, { "path": "../ui-primitives" diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index bd4928f158..4199ec7226 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-api-remotes" ], "platform": "web" @@ -59,7 +60,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -78,7 +80,8 @@ "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 4d2086c4af..0488aba5ee 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../ui-session" }, + { + "path": "../ui-workspace" + }, { "path": "../ui-settings" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index e9cf326b3f..33180942fe 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -32,8 +32,8 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-sidebar" ], "platform": "web" @@ -45,16 +45,16 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-brand-official/tsconfig.json b/packages/client/ui-brand-official/tsconfig.json index f98c0a8b2f..0ba9d872dd 100644 --- a/packages/client/ui-brand-official/tsconfig.json +++ b/packages/client/ui-brand-official/tsconfig.json @@ -12,7 +12,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-conversation" diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 649bb29d57..07c5c18a52 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-client-ui-conversation" @@ -52,18 +51,20 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -73,7 +74,12 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-commands/tsconfig.json b/packages/client/ui-commands/tsconfig.json index 93b067ff95..f4c9d3b26a 100644 --- a/packages/client/ui-commands/tsconfig.json +++ b/packages/client/ui-commands/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -18,7 +21,7 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-conversation" @@ -26,6 +29,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-input-trigger" }, @@ -36,10 +45,10 @@ "path": "../../interaction/commands" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../../core/session" }, { - "path": "../../api/remotes/tsconfig.client.json" + "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index d3fb4c8b13..876a3dbd9d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -40,7 +40,8 @@ "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", - "@deepseek-ai/dsh-client-ui-settings" + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-workspace" ], "platform": "web" } @@ -66,6 +67,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -95,6 +97,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 4035f4616c..30b6de4e7f 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -34,8 +34,9 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -48,25 +49,29 @@ "peerDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^" + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index 7074624096..51e6d50b5d 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -11,14 +11,23 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-conversation" }, { - "path": "../ui-conversation" + "path": "../ui-chat" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-primitives" }, { "path": "../ui-slots" @@ -28,6 +37,9 @@ }, { "path": "../../core/system-prompt" + }, + { + "path": "../../core/session" } ] } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 3f1ef97ed3..1bd81078ab 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -31,8 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-client-ui-workspace/client" + ], "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" ], @@ -48,17 +52,19 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-directory-picker-browse/tsconfig.json b/packages/client/ui-directory-picker-browse/tsconfig.json index c229f13228..24535dac3f 100644 --- a/packages/client/ui-directory-picker-browse/tsconfig.json +++ b/packages/client/ui-directory-picker-browse/tsconfig.json @@ -17,11 +17,14 @@ { "path": "../ui-primitives" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-workspace" diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 48e91703b1..986e38be54 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace" ], "platform": "web" @@ -44,13 +44,13 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", diff --git a/packages/client/ui-directory-picker-native/tsconfig.json b/packages/client/ui-directory-picker-native/tsconfig.json index c5dc5ef93d..75e7cfbb4e 100644 --- a/packages/client/ui-directory-picker-native/tsconfig.json +++ b/packages/client/ui-directory-picker-native/tsconfig.json @@ -15,7 +15,7 @@ "path": "../ui-slots" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-workspace" diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 27ea4a076c..a14e25110e 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -32,10 +32,13 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -46,10 +49,13 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -58,13 +64,16 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 0a6c80a0af..0e81d8e171 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -18,11 +18,20 @@ "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../runtime" + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../ui-chat" }, { "path": "../ui-conversation" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-primitives" }, @@ -35,6 +44,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../typert/protocol" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 9916dc1de7..4489bbe5af 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -31,9 +31,14 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale" + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -48,14 +53,19 @@ }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^" + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -63,7 +73,11 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "@deepseek-ai/dsh-file-reference": "workspace:^" + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-input-trigger/tsconfig.json b/packages/client/ui-input-trigger/tsconfig.json index 0f479a45f4..1570df8e63 100644 --- a/packages/client/ui-input-trigger/tsconfig.json +++ b/packages/client/ui-input-trigger/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -15,14 +18,26 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../../context/file-reference" }, + { + "path": "../../core/session" + }, { "path": "../ui-primitives" }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 5f3b41460f..a195c70e08 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -25,7 +25,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives" ], @@ -47,14 +46,15 @@ }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -62,7 +62,10 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json index fc376a313a..727523a055 100644 --- a/packages/client/ui-jobs/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -8,21 +8,27 @@ "src" ], "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-conversation" }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 3b2bf0da97..b143308743 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -32,7 +32,8 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-theme" ], "platform": "web" @@ -44,14 +45,17 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 23da1ac7c2..ba35af9f86 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -24,7 +24,13 @@ "path": "../ui-primitives" }, { - "path": "../runtime" + "path": "../store" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" }, { "path": "../../runtime-diagnostics/invariants" diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 14034b8478..19df358147 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -32,10 +32,10 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -49,31 +49,37 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-message-feedback/tsconfig.json b/packages/client/ui-message-feedback/tsconfig.json index f46af0e619..36d5d1b762 100644 --- a/packages/client/ui-message-feedback/tsconfig.json +++ b/packages/client/ui-message-feedback/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../core/session" + }, { "path": "../../typert/protocol" }, @@ -27,7 +30,13 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-chat" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" }, { "path": "../ui-conversation" diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 1dfa6f285c..04d454ccb3 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -31,9 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes" ], @@ -50,20 +53,21 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -74,7 +78,11 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-model-selection/tsconfig.json b/packages/client/ui-model-selection/tsconfig.json index 8346dc7425..7dbfc4369d 100644 --- a/packages/client/ui-model-selection/tsconfig.json +++ b/packages/client/ui-model-selection/tsconfig.json @@ -18,7 +18,7 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-commands" @@ -29,6 +29,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-input-trigger" }, @@ -38,6 +44,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../core/session" + }, { "path": "../../api/session-controller/tsconfig.client.json" } diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 55c6b4203a..d178d3dcef 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -31,10 +31,13 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-settings" @@ -50,21 +53,24 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^" + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -74,7 +80,9 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-permission-presets/tsconfig.json b/packages/client/ui-permission-presets/tsconfig.json index 1bb48eee02..bc10ea7c5a 100644 --- a/packages/client/ui-permission-presets/tsconfig.json +++ b/packages/client/ui-permission-presets/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-commands" @@ -23,6 +23,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-input-trigger" }, @@ -38,6 +44,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../ui-settings" } diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 731b0fd8a0..340aa2bceb 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger" @@ -48,7 +47,6 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -59,7 +57,6 @@ "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index 4fdd19dbc2..cd661610fa 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-input-trigger" }, diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 4957b9fcdc..34087f6979 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -31,12 +31,8 @@ }, "dsh": { "client": { - "external": [ - "@deepseek-ai/dsh-api-workspace-controller/client" - ], "inject": [ "@deepseek-ai/dsh-api-session-controller", - "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" @@ -50,7 +46,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^" @@ -58,7 +53,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-session/tsconfig.json b/packages/client/ui-session/tsconfig.json index 075c9117ed..17caaf72e7 100644 --- a/packages/client/ui-session/tsconfig.json +++ b/packages/client/ui-session/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../api/session-controller/tsconfig.client.json" }, - { - "path": "../../api/workspace-controller/tsconfig.client.json" - }, { "path": "../../core/session" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index de5fa695d8..441433fadd 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-connection", @@ -55,18 +54,19 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -76,7 +76,9 @@ "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index cbb773c5dc..e13a2f4ff6 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -18,7 +18,13 @@ "path": "../ui-primitives" }, { - "path": "../runtime" + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../store" }, { "path": "../ui-settings" diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 6b7f46dfe8..5366374f39 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-api-remotes" @@ -49,16 +48,16 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^" + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -66,7 +65,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index 52c590e2ee..dcabd42c03 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -15,11 +15,14 @@ "path": "../ui-slots" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, { "path": "../ui-settings" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index c67689bcad..270e353444 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale" ], @@ -48,15 +47,14 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -66,7 +64,8 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-plugin-inventory/tsconfig.json b/packages/client/ui-settings-plugin-inventory/tsconfig.json index dc07f1b2f8..3127e99d89 100644 --- a/packages/client/ui-settings-plugin-inventory/tsconfig.json +++ b/packages/client/ui-settings-plugin-inventory/tsconfig.json @@ -17,15 +17,15 @@ { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-settings" }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 5c7cf9ceeb..7edd06ff4e 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -34,7 +34,6 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" ], @@ -51,23 +50,24 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-plugins/tsconfig.json b/packages/client/ui-settings-plugins/tsconfig.json index 66472d6745..76fdfcc4f2 100644 --- a/packages/client/ui-settings-plugins/tsconfig.json +++ b/packages/client/ui-settings-plugins/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../../test-support/client-runtime" @@ -23,6 +23,9 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, { "path": "../ui-settings" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index e37334c509..94d9e4c6d7 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes" ], "platform": "web" @@ -51,14 +50,13 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index fa84d80082..29fcccb57a 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -15,7 +15,7 @@ "path": "../ui-slots" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../../../vendor/schemastery" @@ -23,6 +23,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../../settings/settings" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index e617deaf80..50ccb7ed46 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" ], "platform": "web" @@ -54,6 +55,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^" @@ -67,6 +69,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index e04d03aa88..c744093af2 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -31,9 +31,13 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-api-remotes" @@ -48,25 +52,29 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 33bf3ed459..1771d32619 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -11,6 +11,12 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, { "path": "../../../vendor/cordis" }, @@ -18,7 +24,7 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-tool" @@ -34,9 +40,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 16d497e7ff..775289d61b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -31,9 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-input-trigger" @@ -48,17 +51,20 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", @@ -71,7 +77,12 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index ac34489efb..358585ecf7 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -8,27 +8,42 @@ "src" ], "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-conversation" }, + { + "path": "../ui-input-trigger" + }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, { "path": "../../llm/token-meter" }, + { + "path": "../../core/session" + }, { "path": "../../subagent/subagent" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index eed01f67ba..4bb37da9e4 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -33,8 +33,8 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" ], @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -59,9 +59,10 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 83c3924bef..e2f9932485 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -12,7 +12,10 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" + }, + { + "path": "../ui-renderer" }, { "path": "../ui-primitives" diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 1f4411367c..8c93e10f21 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -31,9 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-workspace-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -53,16 +56,18 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -71,7 +76,11 @@ "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 1982d6eee1..7b30739361 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -18,10 +21,10 @@ "path": "../connection/tsconfig.client.json" }, { - "path": "../runtime" + "path": "../locale" }, { - "path": "../locale" + "path": "../ui-chat" }, { "path": "../ui-conversation" @@ -29,11 +32,20 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../core/tools" } ] } diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 9ab72c7270..ac9548938b 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -32,9 +32,12 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -51,9 +54,12 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", @@ -61,12 +67,16 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", diff --git a/packages/client/ui-workflow-run/tsconfig.json b/packages/client/ui-workflow-run/tsconfig.json index 7ef4d4625d..643952f137 100644 --- a/packages/client/ui-workflow-run/tsconfig.json +++ b/packages/client/ui-workflow-run/tsconfig.json @@ -15,11 +15,23 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../store" + }, + { + "path": "../ui-chat" }, { "path": "../ui-conversation" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index c7faf9d701..9083a5bc21 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/web/tsconfig.json b/packages/client/web/tsconfig.json index e31dfe1fa7..5ee451203d 100644 --- a/packages/client/web/tsconfig.json +++ b/packages/client/web/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../store" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 3b183d6839..3bc0539e38 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-modules", "@deepseek-ai/dsh-client-ui-theme" @@ -50,7 +50,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -60,7 +60,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/extensions/cordis-client-runner/tsconfig.json b/packages/extensions/cordis-client-runner/tsconfig.json index a93e5c0fb0..f1ac2c60ca 100644 --- a/packages/extensions/cordis-client-runner/tsconfig.json +++ b/packages/extensions/cordis-client-runner/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../client/modules" }, { - "path": "../../client/runtime" + "path": "../../client/ui-renderer" }, { "path": "../../client/ui-slots" diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index a3742af61e..c71fd086c3 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -32,12 +32,13 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-cordis-client-runner", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -54,9 +55,10 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -66,10 +68,12 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/extensions/ui-cordis/tsconfig.json b/packages/extensions/ui-cordis/tsconfig.json index 049ad3185e..f2d68dff7b 100644 --- a/packages/extensions/ui-cordis/tsconfig.json +++ b/packages/extensions/ui-cordis/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../../client/runtime" + "path": "../../core/session" }, { "path": "../../client/ui-sidebar" @@ -32,6 +32,12 @@ { "path": "../../client/ui-primitives" }, + { + "path": "../../client/ui-renderer" + }, + { + "path": "../../client/ui-session" + }, { "path": "../../client/ui-slots" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 0d5988c0e1..8970f18b9b 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -24,9 +24,10 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, @@ -35,9 +36,11 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -50,9 +53,10 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-commands", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } diff --git a/packages/session-query/session-log-export/tsconfig.json b/packages/session-query/session-log-export/tsconfig.json index ed1f6f6dd8..46ad790e94 100644 --- a/packages/session-query/session-log-export/tsconfig.json +++ b/packages/session-query/session-log-export/tsconfig.json @@ -11,10 +11,13 @@ { "path": "../../../vendor/cordis" }, { "path": "../../interaction/commands" }, { "path": "../../client/locale" }, - { "path": "../../client/runtime" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, { "path": "../../client/ui-commands" }, { "path": "../../client/ui-conversation" }, { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-session" }, { "path": "../../client/ui-slots" }, { "path": "../../runtime-diagnostics/invariants" } ] diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index fb39c32177..84613f30ac 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -32,21 +32,37 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index 63e3a17eba..91a07be843 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -11,6 +11,30 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../core/session" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../client/ui-chat" + }, + { + "path": "../../client/ui-conversation" + }, { "path": "../../client/ui-slots" }, @@ -18,13 +42,13 @@ "path": "../../client/ui-renderer" }, { - "path": "../../client/runtime" + "path": "../../client/ui-session" + }, + { + "path": "../../client/ui-settings" }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../host/apiproxy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caa719cd87..9311ceec08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../packages/client/store '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../packages/client/ui-primitives @@ -1014,6 +1017,12 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -1059,6 +1068,9 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace @@ -1075,6 +1087,12 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -1543,18 +1561,21 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset + '@deepseek-ai/dsh-client-ui-approval': + specifier: workspace:^ + version: link:../../client/ui-approval '@deepseek-ai/dsh-client-ui-attachment': specifier: workspace:^ version: link:../../client/ui-attachment '@deepseek-ai/dsh-client-ui-brand-official': specifier: workspace:^ version: link:../../client/ui-brand-official + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../../client/ui-chat '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../../client/ui-commands @@ -1603,6 +1624,9 @@ importers: '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings @@ -1820,15 +1844,18 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -1863,7 +1890,7 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/client/runtime: + packages/client/store: dependencies: immer: specifier: ^10.1.1 @@ -1875,78 +1902,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:^ - version: link:../../api/gateway - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-api-workspace-controller': - specifier: workspace:^ - version: link:../../api/workspace-controller - '@deepseek-ai/dsh-attachment': - specifier: workspace:^ - version: link:../../attachment/attachment - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../interaction/commands - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-llm-retry': - specifier: workspace:^ - version: link:../../llm/llm-retry - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session/session-projection - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../session/session-title - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../todo/tool-todo - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:^ - version: link:../../util/crypto - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/client/ui-agent-preset: devDependencies: @@ -1956,15 +1914,18 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -1974,15 +1935,75 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-approval: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2002,15 +2023,18 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-ui-chat': specifier: workspace:^ - version: link:../runtime + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2035,15 +2059,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../ui-sidebar @@ -2063,6 +2087,90 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-chat: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-ui-approval': + specifier: workspace:^ + version: link:../ui-approval + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-compaction': + specifier: workspace:^ + version: link:../../compaction/compaction + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-stats': + specifier: workspace:^ + version: link:../../session/session-stats + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-commands: dependencies: clsx: @@ -2075,15 +2183,18 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2096,6 +2207,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2105,6 +2222,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2124,57 +2244,63 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-compaction': - specifier: workspace:^ - version: link:../../compaction/compaction '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry @@ -2184,12 +2310,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode - '@deepseek-ai/dsh-session-projection': + '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../../session/session-projection - '@deepseek-ai/dsh-session-stats': - specifier: workspace:^ - version: link:../../session/session-stats + version: link:../../core/session '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -2199,12 +2322,12 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2223,24 +2346,30 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2260,18 +2389,21 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2299,9 +2431,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ - version: link:../runtime + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../ui-workspace @@ -2329,21 +2461,30 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2384,18 +2525,30 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2405,6 +2558,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2417,12 +2573,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2432,6 +2588,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2453,9 +2615,15 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2486,18 +2654,24 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2507,6 +2681,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -2547,9 +2724,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2565,12 +2742,21 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -2589,15 +2775,18 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2610,6 +2799,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2640,9 +2835,6 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2652,6 +2844,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2661,6 +2859,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2755,9 +2956,6 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger @@ -2786,9 +2984,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2814,6 +3009,36 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-session: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-settings: dependencies: '@deepseek-ai/schemastery': @@ -2829,9 +3054,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2872,15 +3097,21 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2917,15 +3148,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2953,15 +3187,15 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -3002,15 +3236,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -3036,12 +3273,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -3051,9 +3288,18 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -3072,15 +3318,15 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -3090,6 +3336,9 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3099,6 +3348,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3117,6 +3369,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -3129,12 +3384,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -3147,12 +3405,21 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -3193,15 +3460,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -3236,24 +3506,33 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3288,21 +3567,33 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3312,6 +3603,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -3343,33 +3637,45 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../interaction/user-questions @@ -3385,21 +3691,33 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3431,15 +3749,21 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -3449,6 +3773,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../ui-sidebar @@ -3458,6 +3788,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3476,6 +3809,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../modules + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -4509,9 +4845,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ - version: link:../../client/runtime + version: link:../../client/ui-renderer '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -4612,15 +4948,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../../client/ui-input-trigger '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -4636,6 +4975,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -6503,9 +6845,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../../client/runtime + version: link:../../client/store '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../../client/ui-commands @@ -6515,6 +6857,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots @@ -8459,21 +8807,45 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ - version: link:../../client/runtime + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../../client/ui-chat + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../../client/ui-conversation '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../client/ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 4278624bca..8eb9886503 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -63,9 +63,9 @@ describe('client bundle purity gate', () => { const resolveId = purityResolveId() it('leaves default externals and non-scoped specifiers alone', () => { + expect(resolveId('@deepseek-ai/dsh-client-store')).toBeNull() expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull() expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull() - expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull() expect(resolveId('react')).toBeNull() expect(resolveId('zod')).toBeNull() }) @@ -95,14 +95,14 @@ describe('client bundle purity gate', () => { it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => { expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/) - expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-client-ui-session')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/) }) - it('admits the parser-preloaded runtime for every dynamic bundle', () => { - expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull() + it('admits package-specific requests only for the declaring bundle', () => { + expect(resolveId('@deepseek-ai/dsh-api-session-controller/client')).toBeNull() const withoutRequest = purityResolveId('@deepseek-ai/dsh-client-ui-goal') - expect(withoutRequest('@deepseek-ai/dsh-client-runtime/client')).toBeNull() + expect(() => withoutRequest('@deepseek-ai/dsh-api-session-controller/client')).toThrow(/purity/) }) it('externalizes the baseline independently of each package manifest', () => { @@ -114,7 +114,7 @@ describe('client bundle purity gate', () => { expect(requesting.neverBundle('react')).toBe(true) expect(requesting.neverBundle('zod')).toBe(false) expect(plain.neverBundle('react')).toBe(true) - expect(plain.neverBundle('@deepseek-ai/dsh-client-runtime/client')).toBe(true) + expect(plain.neverBundle('@deepseek-ai/dsh-client-store')).toBe(true) }) }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e0c57b3e28..299d0eec76 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -148,13 +148,12 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', + uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API', settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API', settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API', - chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API', + chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API', commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API', - conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API', - conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API', layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API', locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API', modelDirectories: 'client-side interface-typed browser service — packages/client/ui-model-selection/README.md owns the API', @@ -163,9 +162,9 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { sessionLogDownload: 'client-side browser download controller — packages/session-query/session-log-export/README.md owns the API', inputTriggers: 'client-side interface-typed browser service — packages/client/ui-input-trigger/README.md owns the API', timer: 'client-side dynamic-package timer service — packages/extensions/cordis-client-runner/README.md owns the API', - slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API', + slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', - workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API', + workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', } /** @@ -213,13 +212,13 @@ export const EVENT_SCOPE_PAGE: Record = { */ export const EVENT_WALK_EXEMPTIONS: Record = { 'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API', - 'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API', + 'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', - 'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the API', + 'slots/changed': 'client-face slot invalidation signal — packages/client/ui-renderer/README.md owns the API', 'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the API', } diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 134dfad203..ed264e49e2 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -202,12 +202,12 @@ const EXACT_EDITS: readonly ExactEdit[] = [ "@deepseek-ai/.+" ] }, - "packages/util/home": {`, + "packages/host/directory-picker-auto": {`, replace: ` "ignoreDependencies": [ "@deepseek-ai/.+" ] }, - "packages/util/home": {`, + "packages/host/directory-picker-auto": {`, expect: 1, }, { @@ -345,7 +345,7 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ id: 'vendoring-cookbook-name-invariant-zh', file: 'docs/cookbook/adding-a-vendored-package.zh.md', find: '保留上游的 `name`/`version`/`exports`/`type`', - replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`', + replace: '改写 `name` 的 scope([映射](../rescope.zh.md)),保留上游的 `version`/`exports`/`type`', expect: 1, }, { diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts index ca378f4b59..df12891476 100644 --- a/scripts/verify-client-packages.spec.ts +++ b/scripts/verify-client-packages.spec.ts @@ -100,7 +100,7 @@ describe('source package uses', () => { describe('package modes', () => { it('accepts one dynamic package and one statically linked package', () => { - const dynamic = pkg('runtime') + const dynamic = pkg('feature') const shell = pkg('ui-slots', { dynamic: false, staticLinked: true }) expect(collectClientPackageViolations(facts([dynamic, shell]))).toEqual([]) }) @@ -116,11 +116,11 @@ describe('package modes', () => { it('requires seeded workspace packages to use staticLinked and preloads to name dynamic rows', () => { const slots = declaration('ui-slots', { dynamic: false }) - const runtime = declaration('runtime', { dynamic: false }) + const bootstrap = declaration('bootstrap', { dynamic: false }) const found = collectClientPackageViolations(facts([], { - declarations: [slots, runtime], + declarations: [slots, bootstrap], platformModules: [slots.name], - preloadedExternals: [runtime.name + '/client'], + preloadedExternals: [bootstrap.name + '/client'], })) expect(found).toHaveLength(2) expect(found.join('\n')).toContain('does not use the staticLinked preset') @@ -128,14 +128,14 @@ describe('package modes', () => { }) it('requires every preloaded external to have a parser preload row', () => { - const runtime = declaration('runtime') + const bootstrap = declaration('bootstrap') expect(collectClientPackageViolations(facts([], { - declarations: [runtime], - preloadedExternals: [runtime.name + '/client'], + declarations: [bootstrap], + preloadedExternals: [bootstrap.name + '/client'], parserPreloadIds: [], }))).toEqual([ 'packages/client/web/src/platform.ts: parser-preloaded external ' - + '"@deepseek-ai/dsh-client-runtime/client" has no matching PARSER_PRELOAD_IDS row in ' + + '"@deepseek-ai/dsh-client-bootstrap/client" has no matching PARSER_PRELOAD_IDS row in ' + 'packages/client/modules/src/index.ts', ]) }) @@ -144,12 +144,12 @@ describe('package modes', () => { describe('dependency sections', () => { it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => { const slots = pkg('ui-slots', { dynamic: false, staticLinked: true }) - const runtime = pkg('runtime', { + const conversation = pkg('conversation', { inject: ['@deepseek-ai/dsh-client-feature'], sourceUses: { - '@deepseek-ai/dsh-agent': ['packages/client/runtime/src/index.ts'], - '@deepseek-ai/dsh-client-ui-slots': ['packages/client/runtime/src/client/slots.ts'], - react: ['packages/client/runtime/src/client/view.tsx'], + '@deepseek-ai/dsh-agent': ['packages/client/conversation/src/index.ts'], + '@deepseek-ai/dsh-client-ui-slots': ['packages/client/conversation/src/client/slots.ts'], + react: ['packages/client/conversation/src/client/view.tsx'], }, dependencies: { immer: '^10.1.1' }, peerDependencies: { @@ -165,7 +165,7 @@ describe('dependency sections', () => { react: '^18.2.0', }, }) - expect(collectClientPackageViolations(facts([slots, runtime], { + expect(collectClientPackageViolations(facts([slots, conversation], { platformModules: ['react', slots.name], }))).toEqual([]) }) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1cfd6f4491..c6cabd41e6 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -68,14 +68,17 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/store': { kind: 'none', reason: 'Browser-side state primitives; register nothing model-facing.' }, 'packages/client/ui-renderer': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' }, + 'packages/client/ui-session': { kind: 'none', reason: 'Browser-side Session adapter; registers nothing model-facing.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, - 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-brand-official': { kind: 'none', reason: 'Browser-side presentation occupants; registers nothing model-facing.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-approval': { kind: 'none', reason: 'Browser-side approval presentation; registers nothing model-facing.' }, + 'packages/client/ui-chat': { kind: 'none', reason: 'Browser-side Chat presentation; registers nothing model-facing.' }, 'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 26e991faae..195cb5d398 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -197,16 +197,19 @@ "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], + "@deepseek-ai/dsh-client-store": ["./packages/client/store/src/index.ts"], + "@deepseek-ai/dsh-client-store/invariant": ["./packages/client/store/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-renderer": ["./packages/client/ui-renderer/src"], "@deepseek-ai/dsh-client-ui-renderer/client": ["./packages/client/ui-renderer/src/client"], "@deepseek-ai/dsh-client-ui-renderer/invariant": ["./packages/client/ui-renderer/src/invariant.ts"], + "@deepseek-ai/dsh-client-ui-session": ["./packages/client/ui-session/src"], + "@deepseek-ai/dsh-client-ui-session/client": ["./packages/client/ui-session/src/client"], + "@deepseek-ai/dsh-client-ui-session/invariant": ["./packages/client/ui-session/src/invariant.ts"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], - "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], - "@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"], "@deepseek-ai/dsh-cordis-client-runner": ["./packages/extensions/cordis-client-runner/src"], "@deepseek-ai/dsh-cordis-client-runner/client": ["./packages/extensions/cordis-client-runner/src/client"], "@deepseek-ai/dsh-cordis-client-runner/invariant": ["./packages/extensions/cordis-client-runner/src/invariant.ts"], @@ -218,7 +221,12 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-brand-official": ["./packages/client/ui-brand-official/src"], + "@deepseek-ai/dsh-client-ui-approval": ["./packages/client/ui-approval/src"], + "@deepseek-ai/dsh-client-ui-approval/client": ["./packages/client/ui-approval/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-chat": ["./packages/client/ui-chat/src"], + "@deepseek-ai/dsh-client-ui-chat/client": ["./packages/client/ui-chat/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-conversation/client": ["./packages/client/ui-conversation/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 4e4e5c7afd..291b813300 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -42,13 +42,11 @@ // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. { "path": "./packages/host/webserver" }, - // Compaction seam: the client-runtime pin test value-imports the canonical - // checkpoint const from the cordis-free dsh-compaction/checkpoint leaf and - // deliberately never loads the dsh-compaction package root or the host-side - // Context merges reachable through it. The client package pins the same - // leaf through a type-only import in transcript-adapter.ts; composite - // rootDir rules make both paths depend on this runtime project reference. + // Compaction seam: ui-chat type-imports the canonical checkpoint contract + // from the cordis-free dsh-compaction/checkpoint leaf. Composite rootDir + // rules make that path depend on this project reference. { "path": "./packages/compaction/compaction" }, + { "path": "./packages/client/store" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/ui-attachment" }, { "path": "./packages/client/ui-primitives" }, @@ -60,13 +58,14 @@ { "path": "./packages/api/session-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, - { "path": "./packages/client/runtime" }, { "path": "./packages/extensions/cordis-client-runner" }, { "path": "./packages/extensions/ui-cordis" }, { "path": "./packages/test-support/client-runtime" }, { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-brand-official" }, + { "path": "./packages/client/ui-approval" }, + { "path": "./packages/client/ui-chat" }, { "path": "./packages/client/ui-conversation" }, { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables" }, @@ -97,6 +96,7 @@ { "path": "./packages/client/ui-settings-plugin-inventory" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/ui-renderer" }, + { "path": "./packages/client/ui-session" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } ] diff --git a/vitest.config.ts b/vitest.config.ts index 7ac8101425..f24c8c552e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -208,14 +208,26 @@ export default defineConfig({ 'packages/client/ui-primitives/src/RiskConfirmation.tsx', 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', + 'packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx', 'packages/client/ui-renderer/src/client/*', - // This isolated settings-scope lifecycle has complete unit coverage; - // keep it out of the broader client-runtime GUI debt exemption. - 'packages/client/runtime/src/**/!(settings-scope).ts', + // Session object internals retain the runtime GUI debt exemption; the + // new Controller entry, transport, Agent scope, and adapters stay gated. + 'packages/api/session-controller/src/client/sessions/*', + 'packages/api/session-controller/src/client/ordered-baseline.ts', + 'packages/api/session-controller/src/client/time-zone.ts', // Keep the browser conversation tree under its existing GUI debt // exemption while gating the newly stateful Host half and vocabulary. 'packages/client/ui-conversation/src/client/*', 'packages/client/ui-conversation/src/invariant.ts', + // Chat presentation and assembly retain the same GUI debt exemption; + // package wiring and the new approval-detail adapter remain gated. + 'packages/client/ui-chat/src/client/chat/!(ApprovalCommand).{ts,tsx}', + 'packages/client/ui-chat/src/client/conversation-nodes/*', + 'packages/client/ui-chat/src/client/details/*', + 'packages/client/ui-chat/src/client/model/*', + 'packages/client/ui-chat/src/client/contract/context-provenance.ts', + 'packages/client/ui-chat/src/client/contract/snapshot.ts', + 'packages/client/ui-chat/src/client/historical-images.ts', 'packages/client/ui-primitives/src/DisclosureRow.tsx', 'packages/client/ui-tool/src/*', 'packages/client/ui-slots/src/*', From f13fb4daeb143a155b3752950bebff20c1847645 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:20:51 +0800 Subject: [PATCH 196/248] docs(client): document split ownership --- ...lient-session-conversation-ownership.zh.md | 25 ++++++ packages/api/gateway/README.md | 4 +- packages/api/gateway/README.zh.md | 4 +- packages/api/remotes/README.zh.md | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 84 +++++++++---------- packages/client/ui-conversation/README.zh.md | 82 +++++++++--------- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 8 +- .../test-support/client-runtime/README.zh.md | 8 +- packages/typert/protocol/README.md | 4 +- packages/typert/protocol/README.zh.md | 4 +- packages/typert/registry/README.md | 2 +- packages/typert/registry/README.zh.md | 2 +- 14 files changed, 132 insertions(+), 107 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md new file mode 100644 index 0000000000..8bc463529c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Client Session、Conversation 与 UI 所有权分层 + +Status: implemented + +[English](2026-08-20-client-session-conversation-ownership.md) | 中文 + +## 问题 + +通用 Client Runtime 同时承载 Session 与 Workspace 对象、Conversation 组装、React hooks、Slot 注册表和 Store 引擎。领域消费者因此依赖一个持续扩张的聚合包,Session 快照也容易混入事件窗口与具体视图数据。 + +## 决定 + +Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client` 和 `api/workspace-controller/client`,只发布 React-free 快照。`ui-session` 与 `ui-workspace` 提供 React adapter;需要同时读取两个 Controller 的初始选择、blank Session 复用和 New Session 导航归 `ui-workspace`,不形成联合快照。Session 快照不暴露原始事件,`ui-conversation` 从内部事件源组装 Conversation,再由 `ui-chat`、`ui-trajectory` 提供目标视图。Approval 与 Question 各自持有 pending 对象和 Remote Event listener,仅把统一 pending source 登记给 `ui-session`。Store 引擎归 `client/store`,Slot 注册、scope materialization 与 hook 绑定归 `ui-renderer`;`client/runtime` 被删除。 + +## 备选方案 + +**保留 Runtime facade。** 这会继续形成依赖汇点,并允许新代码绕过领域 owner。 + +**让 Controller 直接提供 React hooks。** 这会让协议与状态对象依赖 React,阻止非 React 消费者复用。 + +**把 Conversation 数据放回 Session 快照。** 这会让每个目标视图的结构变化扩大 Session API,并迫使普通消费者理解事件组装。 + +## 后果 + +数据 owner、React adapter 和具体视图可以独立演化,Slot 仍通过标准 props 注入 hook。代价是组合包必须显式装载所需 adapter 和视图插件;缺失具体目标插件时 shell 仍可运行,但不生成该目标视图。 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index fe4834b4ca..2546b0c4e5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -8,7 +8,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. @@ -43,6 +43,6 @@ No direct effect; invoked business Services own any model-visible result. - The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypertLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. -- `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. +- `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index dff5a58599..9281519cda 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -8,7 +8,7 @@ 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 @@ -43,6 +43,6 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source - Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypertLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 -- `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`,但不会重放断线期间的事件。 +- `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index b381751cec..f83e31e63b 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -8,7 +8,7 @@ 当前 Client 组合挂载 Commands、Goal、动态 Cordis、文件与 Session 引用、只读 Host 插件清单、消息反馈、Session Controller 和 Workspace Controller contribution。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 -本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和每 Client 独立的 Host event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 +本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和唯一的 Host Cordis event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 ## 转发的 Host 事件 @@ -41,4 +41,4 @@ Host entry 为每条 Client stream 独立注册 allowlist listener 和队列, - 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 - 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 -- 转发事件不重放;需要可靠恢复的状态必须由 owner 提供查询、cursor 或 opening baseline。 +- 只有仍在等待的作用域 waterfall 会在重连后重放;单向通知仍是相互隔离的 best-effort 投递。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2b24d725a5..081d70e9cd 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 96f67677f690f12929cf691ab5901ded399504d3 -README.zh.md: 05fdcd7a989fb74eb9f86f11cffb4eb77d38088a +README.md: b70bb96cc877279cc29662773eddf6fd7d6453ca +README.zh.md: 604beeb336054376971277446e1690137132c29e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 96f67677f6..b70bb96cc8 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,66 +2,66 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, and turn status), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), details shell, and scope-addressed ConversationController. Tool presentation belongs to [`ui-tool`](../ui-tool/README.md). +`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. +## Conversation assembly -The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. The Hero's leading mark is the independent root-scoped `conversation.hero.brand.mark` slot, with the fish mark as its fallback. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows the current session title, optional lineage controls, and view tabs as ordinary column chrome; ordinary fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). +`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. -Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. +The adapter converts each `SessionEventEntry` to `ConversationEventInput` as `{ event, view? }`: the raw Session event is preserved and the envelope-level tool view is included only when present. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. -The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. +Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. -Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints. +## Shell and standard props -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +The package registers the optional-Session `conversation` shell, strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. -The session header dispatches each current ordinary title and subagent breadcrumb through the optional session-scoped `'conversation.session.header.lineage'` seat, followed by the `'conversation.session.header.actions'` list and the independent `'conversation.session.header.utilities'` list at the right edge. Each lineage owner supplies plain breadcrumb identity and display text; the render site retains the ordinary title as fallback, and an ancestor also supplies its upward-navigation callback. Removing the occupant restores every title without affecting header actions, and optional Session utilities cannot reorder or move either group. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. +View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. -A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). +## Temporary composer entries -The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. A path click through the injected `openFile` asks the Host to open that path (relative paths resolve against the session cwd). A Host or OS refusal opens an in-page dialog with the thrown reason and a Retry of the same path; Cancel, Escape, the close control, and a mask click dismiss it ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md)). +`conversation.composer` is a generic chain. Its complete owner currency is: -The chat flow projects each producer-correlated retry chain into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed attempt's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. A terminal failure renders as a persistent inline status at its turn boundary — beside the settled retry row when retries exhausted — showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. +```ts +export interface ComposerChainProps { + sessionId: SessionId | undefined + session: SessionSnapshot | undefined +} +``` -`TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-user-questions's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md). +A business package may install one entry only while a Remote waterfall request is pending: -`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. +```tsx +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChainSelect, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority. +interface Request { + readonly sessionId: SessionId +} -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +type RequestComposerProps = + PropsRuntime<'conversation.composer'> & { matched: Request } -Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. +const select: ChainSelect = owner => + owner.sessionId === request.sessionId ? request : null -Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. Attached images are part of the submission envelope on every send path: a slash-command submit either consumes them (a claim declaring `images` has them serialized through the hub's `commandImages` plumbing, passed to `claim.submit`, and cleared plus released only on a success outcome) or refuses the whole submission with the `command.imagesUnsupported` notice while draft and images stay in place — a command can never consume the text and strand the images. +const dispose = ctx.slots.register( + { name: 'conversation.composer', select }, + RequestComposer, +) -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. +try { + return await request.result +} finally { + dispose() +} +``` -The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Every non-empty ratio starts with integer rounding. A non-full ratio adds decimal places only while the current precision would round to 100%, stopping at the minimum precision that remains below 100%; only a full cache hit displays 100%, and the precision has no fixed limit. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. +The selector must be a pure function of the owner currency. Its non-null return is delivered to the component as `matched`; `PropsRuntime<'conversation.composer'>` supplies the standard Session and global props. Chain order remains ascending `priority`, then registration order, and the first non-null selector wins. The shell keeps the default composer mounted beneath a takeover. Request state, listeners, response encoding, and any request-specific child slots belong to the business package; they are not carried by `SessionSnapshot` or declared by this core package. -`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. +## Model experience -A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. - -## Model Experience - -None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **The stats-line fallback fold covers the in-window flow only** — without the `sessionStats` projection (an assembly that does not mount the unit), every figure folds the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted and the numbers grow per loaded page. -- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). -- **Sent user messages cannot be edited** — user bubbles retain clock and copy; branch lives only under assistant answers ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)). Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). -- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. -- **The approval panel has no durable grant control** — it supports allow-once and reject only. -- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. -- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels. -- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `user/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority. +None. The package renders browser state and sends user-admitted inputs through Session Controller APIs; it does not construct model requests. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 05fdcd7a98..604beeb336 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,66 +2,66 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离与轮次状态)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、详情壳层,以及按 scope 寻址的 ConversationController。工具展示属于 [`ui-tool`](../ui-tool/README.zh.md)。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩(context compaction)图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 +## Conversation 组装 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。Hero 前方的标记是独立的根作用域 `conversation.hero.brand.mark` slot,未被占用时回退到鱼形标记。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,显示当前会话 title、可选谱系控件和视图标签;普通 fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md))。 +`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。 -别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model-selection,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 +adapter 将每个 `SessionEventEntry` 转换成 `{ event, view? }` 形式的 `ConversationEventInput`:原始 Session event 保持不变,仅在 envelope-level tool view 存在时携带 `view`。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 -视图环是一个 slot:严格会话主体注册在 `children` 表中声明会话作用域的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 +target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。 -Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改会话 fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.zh.md)。 +## Shell 与标准 props -会话页头通过可选的会话作用域 `'conversation.session.header.lineage'` seat 派发当前普通 title 与每一级 subagent 面包屑,随后依次渲染 `'conversation.session.header.actions'` 列表和最右侧独立的 `'conversation.session.header.utilities'` 列表。每个谱系 owner 都会提供纯数据形式的面包屑身份与显示文本;render site 保留普通 title 作为回退,祖先还会提供向上导航的回调。移除 occupant 会恢复每个 title,且不影响页头操作;可选的会话工具不会改变这两个区域的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 +本包注册 optional-Session `conversation` shell、strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 +View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 -Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md))。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 -聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.zh.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。经注入的 `openFile` 点击路径会请 Host 打开该路径(相对路径按会话 cwd 解析)。Host 或操作系统拒绝时,页面内对话框展示抛出的原因,并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框([决策](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 +## 临时 composer entry -聊天流会把每条生产方关联的重试链投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每次失败尝试的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。终态失败会在其轮次边界渲染为持久的内联状态——重试耗尽后与定格的重试行并列——展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 +`conversation.composer` 是通用 chain,其完整 owner currency 为: -审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +```ts +export interface ComposerChainProps { + sessionId: SessionId | undefined + session: SessionSnapshot | undefined +} +``` -`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(当前计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` 工具行属于 [`ui-tool`](../ui-tool/README.zh.md)。 +业务 package 可仅在一个 Remote waterfall request pending 期间安装 entry: -`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 +```tsx +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChainSelect, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md))——并能在重连后从同一权威恢复。 +interface Request { + readonly sessionId: SessionId +} -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 +type RequestComposerProps = + PropsRuntime<'conversation.composer'> & { matched: Request } -逐会话 UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 +const select: ChainSelect = owner => + owner.sessionId === request.sessionId ? request : null -图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。已附加的图片在每条发送路径上都是提交信封的一部分:斜杠命令提交要么消费它们(声明 `images` 的 claim 经 hub 的 `commandImages` 管道序列化图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放),要么以 `command.imagesUnsupported` 通知拒绝整个提交,草稿与图片原样保留——命令不可能消费了文字却把图片留在原地。 +const dispose = ctx.slots.register( + { name: 'conversation.composer', select }, + RequestComposer, +) -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +try { + return await request.result +} finally { + dispose() +} +``` -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。所有非空比率都先按整数舍入。非满命中只有在当前精度会舍入成 100% 时才增加小数位,并在首次得到低于 100% 的结果时停止;只有完整缓存命中才显示 100%,且精度没有固定上限。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.zh.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 - -`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 - -完成的一轮会物化一个有序的 `turn-tail` Conversation Node。它由引擎维护的 `TurnLocation` 提供收尾 Assistant 和 Turn data;renderer 在该 Node 的 IconActions 之前渲染 `conversation.chat.turnTail` chain,并派发包含 Turn、收尾 seq 和 `openFile` 的 `TurnTailOwnerProps`。本包只拥有空位;`@deepseek-ai/dsh-client-ui-deliverables` 把改写工具的 `locations` 累积到 Turn data,并拥有产物行、chip 上限和文案,因此把该插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 +selector 必须是 owner currency 的纯函数。非 null 返回值作为 `matched` 传给组件;`PropsRuntime<'conversation.composer'>` 提供标准 Session 与 global props。Chain 顺序仍按 `priority` 升序,再按注册顺序;首个返回非 null 的 selector 获选。Shell 会在 takeover 下保持默认 composer 挂载。Request 状态、listener、response encoding 和任何 request-specific child slot 都属于业务 package,不进入 `SessionSnapshot`,也不由 core package 声明。 ## 模型体验 -无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与暂缓事项 - -- **统计行的回退折算只覆盖窗口内消息流**:未组合 `sessionStats` 投影单元的装配中,所有数字由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入,数字随加载页数增长。 -- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md))。 -- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;分支只存在于 assistant 回答之下([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md))。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md))。 -- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 -- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 -- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 -- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering 操作会被保存和取消取代;Enter 保存,Escape 取消。 -- **Queue 严格 steering 会保留完整消息**:agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `user/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。 +无。本包渲染浏览器状态,并通过 Session Controller API 发送用户确认提交的输入;它不构造模型请求。 diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index d17ac65b2a..4caac8148b 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.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 packages/test-support/client-runtime/README.md -README.md: 443cadb63d2e290874d7248d7fb104c7340d55c7 -README.zh.md: 596f6a90f1223a6108fd09b66c1451539c8ce1fd +README.md: 0254ade4f174d88f979d44019a4179d8a40fc0ca +README.zh.md: 8d90784cf6f6bc8d10dbba4a1ede3a845ed5330e diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 443cadb63d..0254ade4f1 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotRegistry` and UI renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic. +jsdom slot test runtime for client feature specs: a real Cordis `Context`, the renderer-owned `SlotRegistry`, and the production `UiSession` adapter assembled around typed Session and Workspace Controller doubles. Feature suites exercise declaration, registration, scope, store, injection, rendering, updates, and disposal without copying production renderer or adapter logic. -The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionRuntime`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. +The doubles implement the owner interfaces consumed through Cordis: `TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`, each fixture Session is a `FixtureSession implements SessionFace`, and `stubSettingsScope` implements `SettingsScope`. The runtime mounts `UiSession`, which derives renderer standard sources from Controller bindings. Fixtures publish Session lifecycle state through `updateSessionSnapshot`, Workspace state through `TestWorkspaces.update`, projection values through the Session face, and Conversation input through the Session event feed. Unstubbed `ISession` behavior fails with the missing method name. Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `
` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis. @@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Every consumer is an in-repository Vitest suite; there is no Node-compatible runtime entry. -- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce. +- **Vitest and jsdom only.** Every consumer is an in-repository browser-oriented Vitest suite. The package is not a product plugin or a general Node test harness. +- **Session, Conversation, and Chat fixtures stay separate.** `sessionSnapshot` contains only Session Controller state, `conversationSnapshot` contains target-neutral Conversation state, and `chatSnapshot` contains Chat target state. Tests that exercise assembly provide Session event entries instead of adding Conversation or Chat fields to `SessionSnapshot`. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 596f6a90f1..8d90784cf6 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotRegistry` 与 UI 渲染器,围绕带类型的 session/workspace 测试替身组装。功能套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。 +面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、renderer 所有的 `SlotRegistry` 与生产 `UiSession` adapter,围绕带类型的 Session 和 Workspace Controller 测试替身组装。功能套件无需复制生产 renderer 或 adapter 逻辑,即可测试声明、注册、scope、store、注入、渲染、更新与销毁。 -替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`;`stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionRuntime` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 +替身实现通过 Cordis 消费的 owner 接口:`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`,每个 fixture Session 是 `FixtureSession implements SessionFace`,`stubSettingsScope` 实现 `SettingsScope`。运行时挂载 `UiSession`,由它从 Controller binding 派生 renderer 标准 source。fixture 通过 `updateSessionSnapshot` 发布 Session 生命周期状态,通过 `TestWorkspaces.update` 发布 Workspace 状态,通过 Session face 发布 projection 值,并通过 Session event feed 提供 Conversation 输入。未打桩的 `ISession` 行为会在错误中指出缺失方法。 局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `
` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。 @@ -20,5 +20,5 @@ ## 已知限制与延期工作 -- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。所有消费方都是仓内 Vitest 套件;不存在 Node 兼容的运行时入口。 -- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。 +- **仅用于 Vitest 与 jsdom。** 所有消费者都是仓内、面向浏览器的 Vitest 套件。本包不是产品插件,也不是通用 Node 测试框架。 +- **Session、Conversation 与 Chat fixture 相互分离。** `sessionSnapshot` 只包含 Session Controller 状态,`conversationSnapshot` 包含目标无关的 Conversation 状态,`chatSnapshot` 包含 Chat 目标状态。测试装配过程时应提供 Session event entry,不得向 `SessionSnapshot` 添加 Conversation 或 Chat 字段。 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 84f9f1b31c..3d8df3808a 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -20,9 +20,9 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypertLookupMap` and `TypertContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypertRemoteMap`, `TypertRemoteScopeMap`, and `TypertRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client Remote. -The Host assembly extends `TypertRemoteEventSelection` with the Host events it forwards to consumers, which narrows the `ctx.remote.$on` key face; `TypertForwardableEvent` states the shapes a one-way delivery can carry at all, excluding Scope-bound and answered events. `TypertClientRemote` carries both roles of that surface: consumers subscribe through `$on`, and the Client half owning the host frame sink hands frames over through `$dispatch`. +The Host assembly extends `TypertRemoteEventSelection` with the Cordis events it forwards to consumers, which narrows the `ctx.remote.$on` key face. `TypertForwardableEvent` accepts unscoped `void` notifications and scoped async waterfalls whose final `next()` callback returns the event's result type. `TypertClientEventListener` derives the Client listener from that same `Events` member: Host subject types declared under matching `TypertLookupMap` and `TypertContextMap` keys become Client `Context`, while `AbortSignal`, optional and readonly object fields, arrays, callbacks, and result types are preserved. `TypertClientRemote` exposes only `$mount()` and `$on()`; event transport is private to Gateway. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypertLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Host and Client Context adapters both map `Context -> wire identity` and `wire identity -> Context`; the Host adapter additionally supplies the stable wire declaration, and Host composition may override its synchronous or asynchronous resolver. Policy rejections may use `TypertLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index 97e4734a72..15247f37b0 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -20,9 +20,9 @@ 业务包扩展 `TypertLookupMap` 和 `TypertContextMap`,以关联宿主对象或作用域 Context 与其协议身份。生成的产物扩展 `TypertRemoteMap`、`TypertRemoteScopeMap` 和 `TypertRemoteNamespaceMap`,使客户端导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、网关和客户端 Remote 使用的共享运行时形式。 -Host 装配以转发给消费端的 Host 事件扩展 `TypertRemoteEventSelection`,从而收窄 `ctx.remote.$on` 的键面;`TypertForwardableEvent` 陈述单向投递根本能承载哪些形状,把 Scope 化事件与有返回值的事件排除在外。`TypertClientRemote` 承载该面的两种角色:消费方经 `$on` 订阅,持有 Host 帧 sink 的 Client 半经 `$dispatch` 交出帧。 +Host 装配以转发给消费端的 Cordis 事件扩展 `TypertRemoteEventSelection`,从而收窄 `ctx.remote.$on` 的键面。`TypertForwardableEvent` 接受无作用域且返回 `void` 的通知,以及最后一个 `next()` 回调返回事件结果类型的异步作用域 waterfall。`TypertClientEventListener` 从同一条 `Events` 成员派生 Client listener:在 `TypertLookupMap` 与 `TypertContextMap` 中使用同名 key 声明的 Host subject 类型会变为 Client `Context`,同时保留 `AbortSignal`、可选与只读对象字段、数组、回调和结果类型。`TypertClientRemote` 只公开 `$mount()` 与 `$on()`;事件传输由 Gateway 私有持有。 -查找包与 Context 包同时负责该约定的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。查找提供方或宿主 Context 提供方提供稳定声明与默认解析器,宿主组合可以另行配置同步或异步解析器;策略拒绝可用 `TypertLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责该约定的两侧:声明合并提供静态关联,运行时提供方向 `ctx.typert` 注册身份解析。Host 与 Client Context adapter 都提供 `Context -> wire identity` 和 `wire identity -> Context`;Host adapter 还提供稳定的 wire 声明,Host 组合可以覆盖其同步或异步 resolver。策略拒绝可用 `TypertLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index fa227b1c8f..2f3056735f 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -10,7 +10,7 @@ Package reflection is keyed by `#`. Schemas are keyed by ` Date: Sun, 23 Aug 2026 11:18:15 +0800 Subject: [PATCH 197/248] fixup! refactor(interaction): move Approval and Question into UI owners --- .../client/ui-approval/src/client/index.ts | 10 +++++---- .../tests/ui-approval.client.spec.tsx | 8 +++---- .../client/ui-session/src/client/index.ts | 4 ++-- .../tests/ui-session.client.spec.ts | 22 +++++++++++-------- .../ui-user-questions/src/client/index.ts | 8 +++---- .../tests/browser-plugin.client.spec.ts | 6 ++--- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts index a333924cfa..e94947793c 100644 --- a/packages/client/ui-approval/src/client/index.ts +++ b/packages/client/ui-approval/src/client/index.ts @@ -36,7 +36,7 @@ async function answerApproval( owner: ClientContext, request: ClientApprovalRequest, next: ClientApprovalNext, - attend: (pending: PendingApproval) => () => void, + registerPendingInteraction: (pending: PendingApproval) => () => void, ): Promise { const sessionId = ctx.sessions.scopeOf(owner) if (sessionId === undefined) return next() @@ -48,7 +48,7 @@ async function answerApproval( ...(request.reason === undefined ? {} : { reason: request.reason }), ...(request.signal === undefined ? {} : { signal: request.signal }), }) - const remove = attend(pending) + const remove = registerPendingInteraction(pending) try { return await pending.result } finally { @@ -62,7 +62,9 @@ async function answerApproval( */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries') - const attend = ctx.uiSession.attend(() => 0) + const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( + () => 0, + ) ctx.slots.inject('conversation.composer', () => ctx.slots.register({ name: 'conversation.composer', priority: 1, @@ -74,6 +76,6 @@ export function apply(ctx: ClientContext): void { }, }, ApprovalPanel)) ctx.remote.$on('approval/request', function (request, next) { - return answerApproval(ctx, this, request, next, attend) + return answerApproval(ctx, this, request, next, registerPendingInteraction) }) } diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index c4828b2f29..204af86f03 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -28,7 +28,7 @@ interface PluginBench { readonly ctx: Context readonly listener: ApprovalListener readonly pending: { getSnapshot(): readonly PendingApproval[] } - readonly attend: ReturnType + readonly registerPendingInteraction: ReturnType readonly disposeSlot: ReturnType readonly disposeLocale: ReturnType readonly register: ReturnType @@ -53,7 +53,7 @@ function setupPlugin(): PluginBench { const disposeSlot = vi.fn() const disposeLocale = vi.fn() let pending: readonly PendingApproval[] = [] - const attend = vi.fn((_precedence: (value: PendingApproval) => number) => ( + const registerPendingInteraction = vi.fn((_precedence: (value: PendingApproval) => number) => ( value: PendingApproval, ) => { pending = [...pending, value] @@ -77,7 +77,7 @@ function setupPlugin(): PluginBench { }, } as never) ctx.provide('sessions', { scopeOf } as never) - ctx.provide('uiSession', { attend } as never) + ctx.provide('uiSession', { registerPendingInteraction } as never) ctx.provide('slots', { inject: injectSlot, register } as never) ctx.provide('locale', { register: vi.fn(() => disposeLocale), @@ -89,7 +89,7 @@ function setupPlugin(): PluginBench { ctx, listener, pending: { getSnapshot: () => pending }, - attend, + registerPendingInteraction, disposeSlot, disposeLocale, register, diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index e84a7132da..2af1804cd3 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -282,7 +282,7 @@ export class UiSession extends Service { * @param precedence - deterministic cross-domain precedence; larger values win. * @returns a function that publishes one exact interaction until its disposer runs. */ - attend( + registerPendingInteraction( precedence: (interaction: T) => number, ): (interaction: T) => () => void { const domain = new PendingInteractionDomain(precedence, () => { @@ -297,7 +297,7 @@ export class UiSession extends Service { if (index !== -1) this.pendingDomains.splice(index, 1) this.publishPendingInteractions() } - }, 'uiSession.attend()') + }, 'uiSession.registerPendingInteraction()') return interaction => domain.publish(interaction) } diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 044199513a..63293251fd 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -380,8 +380,10 @@ describe('UiSession pending interactions', () => { const id = sessionId('s1') const listener = vi.fn() const off = service.pendingInteractions.subscribe(listener) - const attendApproval = service.attend(() => 0) - const attendQuestion = service.attend( + const registerApproval = service.registerPendingInteraction( + () => 0, + ) + const registerQuestion = service.registerPendingInteraction( interaction => interaction.kind === 'plan-review' ? 2 : 1, ) listener.mockClear() @@ -390,13 +392,13 @@ describe('UiSession pending interactions', () => { const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id } const question = { key: 'question:1', kind: 'question', sessionId: id } const plan = { key: 'question:2', kind: 'plan-review', sessionId: id } - const removeApproval = attendApproval(approval) + const removeApproval = registerApproval(approval) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval) - const removeDuplicate = attendApproval(duplicate) + const removeDuplicate = registerApproval(duplicate) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate) - const removeQuestion = attendQuestion(question) + const removeQuestion = registerQuestion(question) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question) - const removePlan = attendQuestion(plan) + const removePlan = registerQuestion(plan) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) removeQuestion() @@ -414,10 +416,12 @@ describe('UiSession pending interactions', () => { const ctx = new Context() const bench = createSessionsBench(ctx) const service = createUiSession(ctx, bench) - const attend = service.attend(() => 1) + const registerPendingInteraction = service.registerPendingInteraction( + () => 1, + ) const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') } - const remove = attend(interaction) - expect(() => { attend(interaction) }) + const remove = registerPendingInteraction(interaction) + expect(() => { registerPendingInteraction(interaction) }) .toThrow("ui-session: duplicate pending interaction key 'question:1'") const failure = new Error('pending subscriber failed') diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index a6b6646d3e..2d1aabb92e 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -55,12 +55,12 @@ async function answerQuestion( owner: ClientContext, request: ClientQuestionRequest, next: ClientQuestionNext, - attend: (pending: PendingQuestion) => () => void, + registerPendingInteraction: (pending: PendingQuestion) => () => void, ): Promise { const sessionId = ctx.sessions.scopeOf(owner) if (sessionId === undefined) return next() const pending = new PendingQuestion(sessionId, request.questions, request.signal) - const remove = attend(pending) + const remove = registerPendingInteraction(pending) try { return await pending.result } finally { @@ -76,7 +76,7 @@ async function answerQuestion( */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries') - const attend = ctx.uiSession.attend( + const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( pending => pending.kind === 'plan-review' ? 2 : 1, ) ctx.slots.inject('conversation.composer', () => ctx.slots.register( @@ -89,6 +89,6 @@ export function apply(ctx: ClientContext): void { QuestionComposer, )) ctx.remote.$on('user-questions/request', function (request, next) { - return answerQuestion(ctx, this, request, next, attend) + return answerQuestion(ctx, this, request, next, registerPendingInteraction) }) } diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 5940bc0fba..64baeaf3ee 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -50,13 +50,13 @@ async function bench(declare = true) { )[SESSION_SCOPE]) ctx.provide('sessions', { scopeOf } as never) let pending: readonly PendingQuestion[] = [] - const attend = vi.fn((_precedence: (value: PendingQuestion) => number) => ( + const registerPendingInteraction = vi.fn((_precedence: (value: PendingQuestion) => number) => ( value: PendingQuestion, ) => { pending = [...pending, value] return () => { pending = pending.filter(candidate => candidate !== value) } }) - ctx.provide('uiSession', { attend } as never) + ctx.provide('uiSession', { registerPendingInteraction } as never) let listener: QuestionListener | undefined const on = vi.fn((event: string, value: QuestionListener) => { expect(event).toBe('user-questions/request') @@ -81,7 +81,7 @@ async function bench(declare = true) { agent, scopeOf, pending: { getSnapshot: () => pending }, - attend, + registerPendingInteraction, on, fiber, invoke, From f5767ba15e353f128e16e15dbb049ab98db26384 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:27:16 +0800 Subject: [PATCH 198/248] fixup! docs(client): document split ownership --- ...lient-session-conversation-ownership.zh.md | 452 +++++++++++++++++- 1 file changed, 444 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index 8bc463529c..deeca51740 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -6,20 +6,456 @@ Status: implemented ## 问题 -通用 Client Runtime 同时承载 Session 与 Workspace 对象、Conversation 组装、React hooks、Slot 注册表和 Store 引擎。领域消费者因此依赖一个持续扩张的聚合包,Session 快照也容易混入事件窗口与具体视图数据。 +Web Client 曾由一个通用 Runtime 同时承载 Session 与 Workspace 对象、事件窗口、Conversation 组装、React hooks、Slot 注册表和 Store 引擎。协议状态、业务投影、React 绑定和页面呈现共享同一个依赖汇点,任何一层的变化都可能扩大到完整前端。 + +Session 快照也容易混入事件数组、Conversation View、Chat Node 和待处理交互等并非 Session 自身拥有的数据。普通消费者由此需要理解事件重放与具体视图,新增一个 Conversation target 也可能要求修改 Session、Runtime 和 renderer。 + +React 生命周期与 Session 生命周期之间缺少明确接口时,binding 释放、Hook source 替换和 Slot store 清理会演变为互相回调的专用协议。Approval 与 Question 同时影响侧边栏状态和 composer takeover;若两处各自维护状态,它们还可能选择不同的待处理请求。 + +需要把数据 owner、React adapter、通用渲染机制和具体视图拆成单向依赖,同时保持既有应用行为。 ## 决定 -Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client` 和 `api/workspace-controller/client`,只发布 React-free 快照。`ui-session` 与 `ui-workspace` 提供 React adapter;需要同时读取两个 Controller 的初始选择、blank Session 复用和 New Session 导航归 `ui-workspace`,不形成联合快照。Session 快照不暴露原始事件,`ui-conversation` 从内部事件源组装 Conversation,再由 `ui-chat`、`ui-trajectory` 提供目标视图。Approval 与 Question 各自持有 pending 对象和 Remote Event listener,仅把统一 pending source 登记给 `ui-session`。Store 引擎归 `client/store`,Slot 注册、scope materialization 与 hook 绑定归 `ui-renderer`;`client/runtime` 被删除。 +Client 采用“Controller 与领域对象 → UI adapter → renderer → Slot component”的分层。Controller 和领域对象发布不依赖 React 的 observable source;所属 `ui-*` package 声明标准 props 并注册 source;`ui-renderer` 在 Slot binding 点生成 selector hook;组件只从 Slot props 读取数据与操作。 + +```text +[Remote / Controller / domain object] + | + | bare observable source + v + [ui-* adapter] + | + | standard source registration + v + [ui-renderer] + | + | selector hook binding + v + [Slot component] +``` + +Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client` 和 `api/workspace-controller/client`。Conversation 的 target-neutral 数据结构和组装归 `client/ui-conversation`,Chat 与 Trajectory 分别归 `client/ui-chat` 和 `client/ui-trajectory`。 + +Session 与 Workspace 的 React 适配分别归 `client/ui-session` 和 `client/ui-workspace`。Store engine 归 `client/store`,Slot registry、scope materialization 和 observable-to-hook 绑定归 `client/ui-renderer`。 + +系统不提供聚合式 `client/runtime` package,也不设置替代它的总控 facade。Session history、Remote stream、分页 cursor 和重连连续性由 [Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md) 定义;本 Note 从 Controller 发布的 Client 对象与 source 开始。 + +## 分层原则 + +### Controller 是无 React 的逻辑 owner + +Controller 可以作为 Cordis service 安装,但不拥有 React Context、React hook、Slot props 或组件。Controller snapshot 只包含自身拥有的事实,命令只改变 Host 或领域对象状态。 + +UI 层可以同时读取多个 Controller 做一次导航决定,但不得把组合结果写回任一 Controller snapshot。UI adapter 也不复制 Controller 命令的业务实现。 + +### UI adapter 拥有 React 接入 + +每个标准 hook 归最接近其数据语义的 `ui-*` package。 + +| Hook | Owner | Source | +| --- | --- | --- | +| `useSessions` | `client/ui-session` | Session Controller 全局列表 | +| `useSession` | `client/ui-session` | 当前 Session snapshot | +| `useProjection` | `client/ui-session` | 当前 Session keyed projection | +| `useSessionPendingInteraction` | `client/ui-session` | pending domain 聚合结果 | +| `useWorkspaces` | `client/ui-workspace` | Workspace Controller 列表 | +| `useConversation` | `client/ui-conversation` | Conversation binding snapshot | +| `useChat` | `client/ui-chat` | `chat` target source | +| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source | + +`ui-renderer` 只实现通用绑定,不 import Session、Workspace、Conversation、Chat 或 Trajectory 的业务类型和值。 + +### Slot scope 与标准 props 分离 + +`ui-slots` 声明 root、session 和 session-maybe scope,以及可通过 declaration merge 扩展的标准 props 类型;它不决定每个 scope 安装哪些 hook。 + +`ui-renderer` 实现通用 scope adapter 与 source materialization。`ui-session` 安装 Session scope 并提供内建 source,其他领域 package 只注册自己的 source 和消费它的 Slot entry。 + +新增 target 不要求 renderer 或 Session Controller 增加分支。数据 owner 负责状态身份、更新、错误和释放;UI adapter 负责 hook;显示 owner 负责 target-specific projection 与交互状态。 + +## Package 所有权 + +| Package | 拥有内容 | 明确不拥有 | +| --- | --- | --- | +| `api/session-controller/client` | Session 对象、列表、选择、命令、projection、queue、事件窗口和 Agent Context | Conversation target、React、Slot、Workspace | +| `api/workspace-controller/client` | Workspace 对象、顺序、归档、命令和 snapshot | React、Session 导航策略、目录 UI | +| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 | +| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 | +| `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot | +| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory | +| `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot | +| `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election | +| `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election | +| `client/store` | React-free store contract 与实现 | 领域对象、React hook、Slot 生命周期 | +| `client/ui-renderer` | SlotRegistry、scope binding、selector hook、outlet 和 React root | Session、Workspace 与 Conversation 业务逻辑 | + +## 总体数据流 + +Session 数据按以下路径进入 UI: + +```text +[ctx.remote.session] + | + v +[api/session-controller/client] + |-- SessionListState --------------------------> [ui-session] -> useSessions + |-- SessionSnapshot ----------------------------> [ui-session] -> useSession + |-- ProjectionValueSource ----------------------> [ui-session] -> useProjection + `-- per-Session SessionEventSource + | + v + [client/ui-conversation] + | + | assemble + v + ConversationSnapshot ----------------> useConversation + | + |---------+----------| + v v + [ui-chat] [ui-trajectory] + | | + useChat useTrajectory +``` + +Workspace 数据从 `ctx.remote.workspace` 进入 Workspace Controller,再由 `ui-workspace` 暴露为 `useWorkspaces`;需要跨域导航时,`ui-workspace` 临时读取 Session Controller 并发出选择或命令。 + +Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI owner。Owner 发布 Pending 对象,`ui-session.pendingInteractions` 再把同一对象送往 Session 导航状态和 Conversation composer selection。 + +## Session Controller Client + +### SessionSnapshot 的范围 + +`SessionSnapshot` 表示 Session 自身的控制与生命周期事实。它可以包含 identity、running、removed、blank、subagent address、open phase、history phase、prompt error、agent error 和 queue 状态。 + +它不包含以下数据: + +- raw event array; +- Conversation View; +- Chat Node; +- Trajectory row; +- Approval 或 Question 的待处理对象; +- 要求调用者遍历 event 才能解释的呈现状态。 + +字段由 event、control frame 或本地命令推导,并不自动决定其 owner;消费语义决定 owner。`composerPhase` 同时依赖 Session lifecycle 与 Conversation target activity,因此由 `ui-conversation` 合成,不进入 `SessionSnapshot`。 + +### 三个读取面 + +Session Controller 对外提供三个互不替代的读取面: + +1. 全局 Session list 与 current selection source,供导航和 `useSessions` 使用。 +2. 每个 Session 的逻辑 binding,包含 `sessionId`、`SessionSnapshot` source、commands 与 projection sources。 +3. Conversation-facing `SessionEventSource`,只供 Conversation assemble core 使用。 + +普通 UI component 不直接读取 `SessionEventSource`。`ui-session` 不读取私有 event window,`ui-conversation` core 也不接收 React binding 或 Slot API。 + +### SessionEventSource + +`SessionEventSource` 暴露已经物化的事件窗口,而不是 transport。 + +窗口携带有序 `entries`、`hasMore`、单调 `revision`,以及 `replace | prepend | append` 变更描述。 + +首次打开、重连、gap repair 和无法证明连续性的更新发布 `replace`;历史分页发布 `prepend`;连续 live event 发布 `append`。Conversation core 依据 revision 与 change 选择增量更新或完整 rebuild。 + +`MutableSessionEventSource` 是 Session Controller 内部写端,消费者只依赖只读的 `SessionEventSource`。 + +### Session binding 生命周期 + +每个 Session binding 持有自己的 Cordis Context 与 Fiber。Session Controller 创建 binding,也负责释放它。 + +依赖 Session 的对象把清理注册到 `binding.ctx.effect()`。Binding 释放会触发 Conversation binding、UI materialization 和 scoped Slot store 的清理,不存在额外的 `onBindingRelease` 或 `onRelease` 回调协议。 + +这种清理方式不要求 Session Controller 了解上层消费者名册。 + +## UI Session + +### 服务职责 + +`client/ui-session` 是 Session Controller 与 React/Slot 系统之间唯一的 Session adapter。它提供 `ctx.uiSession`,并负责: + +- 观察 Session list、current selection 和 per-Session binding; +- 安装 session 与 session-maybe scope adapter; +- 提供 `SessionProvider` 的呈现语义; +- 内建 session snapshot、projection 和 sessionId source; +- 接收其他领域 package 的 Session-scoped source contribution; +- 聚合业务 package 注册的 pending interaction。 + +它不拥有 Session transport、event folding、Conversation target 或具体业务结果。 + +### 标准 source 注册 + +领域 package 调用 `ctx.uiSession.provide()` 注册 bare source。Descriptor 静态声明 hooks、keyedHooks 和 props 名册,`resolve(binding)` 为一个 Session binding 返回完全对应的值;例如 `ui-conversation` 把每个 binding 的 snapshot 注册为 `conversation` hook source。 + +普通 source 被 renderer 转换成 `use`,Projection 等开放 key 空间通过 keyed hook resolver 暴露,稳定值通过 props 暴露。 + +运行时拒绝未声明、缺失或重复的标准 prop。`ui-session` 自身也走相同 materialization,renderer 不为 Session 名字写特殊分支。 + +### Scope binding + +session 与 session-maybe 使用同一个 adapter,但绑定语义不同: + +- strict session scope 在没有 current binding 时拒绝渲染; +- session-maybe 使用稳定 absent binding,保持 hook 调用顺序; +- current Session 切换以 `sessionId` 为 key 重建严格 Session subtree; +- root 与 session-maybe entry 可以跨 Session 切换常驻。 + +每个真实 materialized binding 保留 Controller binding 的 Context。`ui-session` 通过 `binding.ctx.effect()` 删除缓存项并撤销 current binding。 + +Contribution roster 变化会重建已 materialize 的 binding 并发布新的 source 集合。同一 binding 生命周期内,source identity 保持稳定,以满足 `useSyncExternalStore` 的缓存要求。 + +### SessionProvider + +`SessionProvider` 是 `PropsRenderSlots` 根据 session-scoped child 声明派生的标准席,不是业务 component 直接 import 的 React Context。 + +它接收普通 `ReactNode` children,不接收 `(sessionId) => ReactNode` render function;调用方直接用它包裹 `renderSlot('details', {})`。 + +Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provider 只负责 absent branch 与按 Session identity 隔离 subtree,组件不得借助 Provider 回调取得 Session 数据。 + +### Pending interaction + +`SessionPendingInteractionMap` 由业务 package declaration merge 扩展。每个 pending object 至少携带稳定 `key`、领域 `kind` 和 `sessionId`;`ui-session` 不 import Approval 或 Question 的具体类型。 + +业务 plugin 在 `apply()` 中调用 `registerPendingInteraction(precedence)`,为自己的 pending domain 建立稳定注册。该调用返回逐请求 publication function;publication function 发布一个精确对象,并返回移除该对象的幂等 disposer。 + +相同 key 的并发对象被拒绝,替换请求必须使用新 key。同一 Session 可以同时存在多个领域或多个请求。 + +`ui-session` 使用各 domain 的 precedence 选出每个 Session 当前生效的对象。较高 precedence 胜出,相同 precedence 下后遍历到的有效对象胜出。 + +聚合结果发布为 `pendingInteractions: ObservableSnapshot>`,`useSessionPendingInteraction` 是其 React 读取面。 + +Session 导航状态和 composer takeover 必须读取同一个 effective object,不得分别维护 status map 或 takeover roster。 + +## Workspace Controller 与 UI Workspace + +### WorkspaceSnapshot 的范围 + +`WorkspaceSnapshot` 只包含 Workspace Controller 拥有的 Host-authoritative 数据,包括 Workspace rows、顺序、archive set、follow phase 和错误。Workspace row 的 `sessionIds` 是关联字段,不等于把 Session 对象复制进 Workspace snapshot。 + +以下组合事实不进入 `WorkspaceSnapshot`: + +- Workspace 与 Session 两条 baseline 是否同时 ready; +- 根据 Session 更新时间推导的最近 Workspace; +- 当前 Session 是否因归档而清除; +- New Session 应复用哪个 blank Session; +- 首次启动应选择哪个 Session。 + +### UI Workspace 的组合职责 + +`client/ui-workspace` 把 Workspace list source 注册为 root 标准 source `workspaces`,renderer 由此提供 `useWorkspaces`。 + +初始选择、blank Session 复用、新建导航、并发创建合并和归档后导航属于 UI navigation policy。该 policy 可以在决定时同时读取 `ctx.workspaces` 与 `ctx.sessions`,但只调用 Controller command 和 selection action,不发布联合 snapshot。 + +目录 picker、目录浏览和 `openPath` 属于独立目录能力,不进入 Workspace Controller。 + +## UI Conversation + +### Assemble core + +`client/ui-conversation` 同时包含不依赖 React 的 Conversation assemble core 和同领域的 React adapter。 + +Core 拥有 `ConversationSnapshot`、Definition registry、View registry、event assembler、location index、每 Session binding、target source 和 target activity。 + +Core 从 Session binding 取得 `SessionEventSource`。连续 revision 的 append 与 prepend 使用增量组装;replace 或 revision 断档从完整窗口 rebuild。 + +Definition 或 View roster 变化只重建 Conversation binding,不重建 Session 或重开 Remote stream。Core 不 import React,可独立测试事件折叠、增量更新和 registry lifecycle。 + +`ConversationSnapshot` 不复制 `SessionSnapshot`,也不暴露 raw events;它只发布 target-neutral 的 View 名册、target activity 和 target source lookup。 + +`useSession` 与 `useConversation` 来自两个 source,不承诺在同一个 React commit 原子发布。同时读取两者的组件按当前 snapshot 纯计算,不把通知顺序解释为业务因果。 + +### Definition 与 View registry + +`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。 + +Registry 拒绝重复 key,保持注册顺序并返回幂等 disposer。Roster 变化时,现有 Conversation binding 使用当前 event window 重建。 + +Target package 通过 declaration merge 扩展 snapshot 与 location data map,再向 registry 注册自己的 Definition、builder 和 View。注册随 Cordis effect 释放。 + +`ui-conversation` 不 import 具体 target package。 + +### Conversation React adapter + +React adapter 把每个 Conversation binding 的 snapshot 注册为 Session 标准 source `conversation`,renderer 由此提供 `useConversation`。 + +同包还拥有 shell、input、composer chain、queue UI、draft、View navigation 和 phase 合成;Core 不读取 React Context、Slot props 或 component state。 + +View 选择顺序固定为:有效的持久化 selection、已注册的 `chat`、无 View。无效 selection 不覆盖持久化值,系统不 fallback 到第一个已注册 View。 + +没有 `ui-chat` 时 shell 仍能激活和 mount,但不会隐式选择 Trajectory 或其他 target。 + +Shell phase 由 Session lifecycle 与 Conversation target activity 纯合成。Session 已 active 或任一 target 报告可见内容时显示 active;首条 prompt 失败仍保持 engaging。 + +### Input 与 composer + +Composer chain 属于 `ui-conversation`,具体 takeover 属于业务 package。`ConversationRoot` 从 `useSessionPendingInteraction` 读取当前 Session 的 effective object,并作为 `ComposerChainProps.pendingInteraction` 交给 chain selector。 + +Selector 是 owner currency 的纯函数,非 null 结果作为 `matched` 传给获选 component。Stable composer entry 与默认 composer 可以同时常驻,chain 只选择一个有效呈现。 + +Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue command 通过 Session-scoped service 寻址,不把 queue UI 写入 Conversation core。 + +## Chat 与 Trajectory target + +### Chat owner + +`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。 + +它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。 + +Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。 + +历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。 + +### Trajectory owner + +`client/ui-trajectory` 通过相同 target 协议注册 `trajectory`。它拥有事件记录、时间线、虚拟行、selection 和 inspection view,并通过标准 source 提供 `useTrajectory`。 + +Session 生命周期读取 `useSession`,Trajectory 数据读取 `useTrajectory`。Trajectory 不通过 Session snapshot 或 Chat snapshot 取得自己的数据。 + +其他 target 使用同一注册流程,不修改 renderer、Session Controller 或 ui-session。 + +## Approval 与 Question + +### 稳定注册 + +Approval 和 Question 的 plugin 安装分为稳定注册与单次请求处理。`apply()` 注册 locale、调用 `registerPendingInteraction()` 注册本领域 pending domain,并向 `conversation.composer` 注册唯一稳定 entry。 + +Approval 的 detail child Slot 也由稳定 entry 声明。并发请求和 Session 数量不会增加 composer entry 或重复声明 Slot,所有注册随 plugin fiber 释放。 + +### 单次 waterfall 请求 + +Remote Event listener 从自身 Agent Context 解析 Session。没有 Session scope 时调用 `next()` 继续 waterfall;存在 Session scope 时创建 `PendingApproval` 或 `PendingQuestion`。 + +Listener 通过已注册 domain 的 publication function 发布对象,等待用户完成、取消或请求 signal 中止,并在 `finally` 中精确移除对象。 + +单次请求不注册 Slot,不创建第二套 lifecycle effect,也不修改 Session snapshot。 + +Approval 暴露 allow 与 reject,Question 暴露 answer 与 cancel。用户主动取消 Question 返回 `ASK_CANCELLED`;等待中的请求被 `AbortSignal` 中止时返回 `UserQuestionError(ASK_ABORTED)`,不泄漏载体的 `AbortError` 或普通 `Error`。 + +Gateway 只要求 Remote Event 参数和结果是合法 JSON 传输值,不复制 Question 选项的领域校验。 + +### 单一 pending 投影 + +Sidebar 与 composer 使用相同 `pendingInteractions` snapshot。导航根据 effective object 的 `kind` 显示审批、计划审阅或问题状态,composer entry 根据对象实例选择自己的面板。 + +同一请求 identity 同时驱动两处 UI。新请求替换同类型旧请求时使用新 key,因此 selector 与订阅者都观察到身份变化。 + +`ui-session` 只实现跨领域 precedence,不解释 Approval 或 Question 的字段。 + +## UI Renderer 与 Store + +### UI Renderer + +`client/ui-renderer` 拥有 `SlotRegistry` service 和 React renderer。它负责: + +- `ctx.slots.register()`、`inject()`、`renderSlot()` 与声明生命周期; +- root、session 和 session-maybe scope adapter; +- 标准 observable source 到 selector hook 的绑定; +- Slot outlet、错误隔离、root mount 与 hydration; +- 按 scope key 管理 Slot store instance 生命周期。 + +Renderer 可以认识通用 scope 名称和 binding 协议,但不读取领域 service。渲染 Session scope 而没有安装 adapter 是装配错误,并立即失败。 + +### Store + +`client/store` 是 React-free 普通库,拥有 `ObservableSnapshot`、`SnapshotStore`、`defineStore`、`createSnapshotStore` 和 `shallowEqual`。 + +`ui-slots` 引用 store contract,`ui-renderer` 管理 store instance 并提供 `useStore`。 + +Store 只承载 draft、View selection、Chat selection、inspection request 和面板尺寸等观看或交互状态。Session、Workspace、Conversation、Remote stream 和 connection generation 不进入 Store。 + +### 注册与释放顺序 + +一个 plugin 同时提供 source 与 Slot entry 时,先注册 source,再注册 entry。Cordis 反向 disposal 先移除 entry,再移除 source,仍挂载的 entry 因而不会短暂失去必需 hook。 + +Session binding 释放通过 `binding.ctx.effect()` 清理 UI materialization 与 scoped store。Plugin fiber 释放通过 registration disposer 清理 source、listener 和 Slot entry。 + +所有 disposer 都可重复调用,不依赖 Cordis 生命周期以外的隐式回调。 + +## 组合与依赖方向 + +应用 bundle 显式安装所需 Controller、adapter、target 和 renderer plugin。每个 owner 的 `apply()` 只安装自己的 service、listener 和 contribution。 + +运行时消费方向是 `session-controller → ui-session → ui-conversation → target UI`、`workspace-controller → ui-workspace` 和 `store → ui-slots → ui-renderer`;Approval 与 Question 只依赖 `ui-session` 提供的 pending 注册点。 + +图中的箭头表示运行时消费关系,不覆盖 type-only declaration merge 边。Controller 不反向依赖 UI adapter,renderer 不反向依赖领域 package,Conversation core 不依赖具体 target。 + +UI component 不接收 `ctx`。跨 package 协作使用 Cordis service、standard source 或 Slot registration,不新增聚合 facade。 + +## 开发者遵循方式 + +### 先确定数据 owner + +新增状态前先按消费语义确定唯一 owner:Host 通信、命令和实体生命周期归 API Controller;由 Session events 形成且与 target 无关的数据归 Conversation core;只服务一种 View 的投影归对应 target package;草稿、选择和面板状态归拥有该交互的 UI package。 + +同一事实不得同时保存在 Controller snapshot、Conversation snapshot 和 Store。需要跨域决策时读取多个 source 并立即发出 command,不创建联合 snapshot,也不缓存另一领域的对象副本。 + +以下信号表示 owner 选择错误:Controller 开始 import React;renderer 出现业务类型分支;组件遍历 Session events;Store 保存 Session 或 Workspace 实体;一个 target 的变化要求修改 Session Controller。 + +### 新增 Session-scoped 数据 + +1. 在领域 owner 中提供 React-free observable source。 +2. 在所属 UI adapter 中 declaration-merge 标准 prop 类型。 +3. 通过 `ctx.uiSession.provide()` 声明固定 roster,并从 Session binding 解析 source。 +4. 让 Slot component 从 `PropsRuntime` 获得生成的 hook,不向组件传 `ctx`。 +5. 把每个 binding 的资源清理挂到 `binding.ctx.effect()`,把 registration 清理留给 plugin fiber。 +6. 测试缺失值、重复名字、roster 替换、Session 切换和 binding disposal。 + +只有开放 key 空间使用 keyed hook;有限且稳定的 source 使用普通 hook;不会变化的标识使用 prop。不得为了减少一次注册而把业务名称硬编码进 renderer。 + +### 新增 Conversation target + +1. 在 target package 中扩展 Conversation snapshot 或 location data map。 +2. 向 `UiConversation.events` 注册所需 event Definition。 +3. 向 `UiConversation.views` 注册 snapshot builder、target id、View 与 activity 规则。 +4. 通过 `ctx.uiSession.provide()` 暴露该 target 的标准 selector hook。 +5. 在同一 package 中注册 renderer、locale 和 target-specific Slot entry。 +6. 验证 target 卸载只重建 Conversation binding,不改变 Session、其他 target 或 Remote stream。 + +Target 不得读取另一个 target 的 snapshot 作为自己的数据源。可选协作通过窄 port 或 Slot 完成;缺失 target 时,shell 必须保持可启动且不得猜测 fallback。 + +### 新增 pending-interaction 业务 + +1. 业务 package 定义 Pending 对象及其完成、取消和中止语义。 +2. 通过 declaration merge 把对象加入 `SessionPendingInteractionMap`。 +3. 在 `apply()` 中调用 `registerPendingInteraction()` 一次,并注册唯一稳定的 composer entry。 +4. Remote waterfall listener 从 Agent Context 解析 Session;无法处理时调用 `next()`。 +5. 可处理时创建 Pending 对象,使用 publication function 发布,等待结果,并在 `finally` 中移除。 +6. 测试并发 key、precedence、用户取消、transport abort、plugin disposal 和无 Session delegation。 + +单次请求不得注册 Slot、声明 child Slot、修改 Session snapshot 或另建状态索引。Sidebar 与 composer 都从 `useSessionPendingInteraction` 读取同一个 effective object。 + +### Review 检查点 + +- 每个新 source、registry contribution、listener 和 cache 都有明确 Cordis fiber 或 Session binding owner。 +- 每个公共 hook 能追溯到唯一 React-free source;不存在只为传参而层层转发的 selector。 +- 每个 component 的数据与 action 都来自标准 props 或所属 Slot inject face。 +- 每个 target 在缺席、动态注册和卸载时都有定义明确的结果。 +- 每个跨层 import 都沿 Controller、adapter、renderer、component 的单向关系前进。 +- 每个错误由最早能解释其语义的 owner 归类;载体错误不直接泄漏成业务错误。 + +## 验证 + +各 owner 的测试分别固定 Controller binding 与 event source、UI scope 与 pending precedence、Conversation 增量组装与 View fallback、target projection、waterfall 结果以及 renderer 的 scope/store 生命周期。应用组装测试同时覆盖完整 roster 和缺少具体 target 的启动;组件测试不替代对象层、重放和生命周期测试。 ## 备选方案 -**保留 Runtime facade。** 这会继续形成依赖汇点,并允许新代码绕过领域 owner。 - -**让 Controller 直接提供 React hooks。** 这会让协议与状态对象依赖 React,阻止非 React 消费者复用。 - -**把 Conversation 数据放回 Session 快照。** 这会让每个目标视图的结构变化扩大 Session API,并迫使普通消费者理解事件组装。 +- **保留 Runtime facade。** 它维持单一入口,却继续形成依赖汇点并允许新代码绕过领域 owner;系统因此不保留 facade 或兼容出口。 +- **把所有 Client 状态放进 API Controller。** 这会让协议对象承担 React、View 和 presentation policy;Controller 因而只保留无 React 的领域状态。 +- **让 Controller 直接提供 React hooks。** 这会阻止非 React 消费者复用同一对象,也使 transport 与 renderer 生命周期相互依赖。 +- **把 Conversation 放进 SessionSnapshot。** 这会扩大 Session API,并迫使普通 Session 消费者理解 event folding 与 target roster。 +- **让 Chat 和 Trajectory 各自重放 Session events。** 这会重复维护顺序、location 和 registry rebuild;共享 assemble core 因而留在 `ui-conversation`。 +- **把 Conversation core 拆成额外的非 UI package。** Core 与 adapter 当前共同演化且没有其他非 UI package 消费者;同包目录隔离足以保持 React-free core。 +- **把 Workspace 与 Session 合成联合 snapshot。** 这会制造新的跨域 owner;跨域逻辑保留为 `ui-workspace` 的即时决策。 +- **让 renderer 内建所有标准 hook。** 这会要求通用基础设施认识每个领域;standard source registration 保持 renderer 与业务类型解耦。 +- **让每个 pending 请求动态注册 composer entry。** 这会重复声明 child Slot,并让并发请求竞争注册顺序;稳定 entry 与请求期对象发布保持分离。 +- **把 pending interaction 写回 Session projection。** 待回答 waterfall 不是已提交的持久 Session 事实,刷新恢复由 Remote Event replay 负责,因此它留在业务 UI source。 +- **为 binding 增加专用 release callback。** 这会重复 Cordis 生命周期;`binding.ctx.effect()` 已能把消费者清理挂到同一 owner。 +- **让 SessionProvider 通过 render function 传 Session id。** 这会产生另一条数据注入路径;普通 children 与标准 `sessionId` prop 保持 scope 数据只有一个入口。 +- **把 Store 留在 renderer。** Store contract 不依赖 React,并被对象与测试基础设施复用;独立 `client/store` 保持 engine 与渲染生命周期分离。 ## 后果 -数据 owner、React adapter 和具体视图可以独立演化,Slot 仍通过标准 props 注入 hook。代价是组合包必须显式装载所需 adapter 和视图插件;缺失具体目标插件时 shell 仍可运行,但不生成该目标视图。 +Session、Workspace、Conversation 与具体 target 各自拥有一份权威状态,非 React consumer 可以直接复用 Controller 和 assemble core。新增 Conversation target 只需注册 Definition、builder、View、标准 source 和 Slot entry;新增 pending-interaction 业务只需声明类型、注册 domain 并提供稳定 composer entry。 + +Renderer 和 Session Controller 不因新增业务领域而增加分支,Session binding 与 plugin fiber 则提供两条明确且可组合的释放路径。UI 可以观察到 Session 与 Conversation source 的独立发布,消费者不得依赖二者的通知顺序。 + +组合包必须显式装载所需 adapter 与 target plugin。缺失具体 target 时 shell 仍可运行,但不会生成或猜测该 target 的 View。更多 package 和显式注册增加了装配工作,但依赖方向、测试范围与故障 owner 均可局部确定。 From dc92793f10abe154615ae146f996de6fb0db1081 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:15:20 +0800 Subject: [PATCH 199/248] fix(client): align domain split with repository gates --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 2 +- ...26-07-19-gui-web-client-architecture.zh.md | 2 +- ...ient-tool-presentation-ownership.i18n.yaml | 4 +- ...8-08-client-tool-presentation-ownership.md | 2 +- ...8-client-tool-presentation-ownership.zh.md | 2 +- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 12 +- ...09-client-conversation-node-assembly.zh.md | 12 +- ...cancelled-stream-prefix-finalize.i18n.yaml | 4 +- ...-08-10-cancelled-stream-prefix-finalize.md | 2 +- ...-10-cancelled-stream-prefix-finalize.zh.md | 2 +- ...t-session-conversation-ownership.i18n.yaml | 6 + ...0-client-session-conversation-ownership.md | 461 ++++++++++ ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 4 +- ...eb-transcript-log-ordered-projection.zh.md | 4 +- ...e-blank-session-reuse-membership.i18n.yaml | 4 +- ...orkspace-blank-session-reuse-membership.md | 2 +- ...space-blank-session-reuse-membership.zh.md | 2 +- ...-attribution-observed-top-ledger.i18n.yaml | 4 +- ...-scroll-attribution-observed-top-ledger.md | 2 +- ...roll-attribution-observed-top-ledger.zh.md | 2 +- ...6-08-02-web-thinking-tail-scroll.i18n.yaml | 4 +- .../2026-08-02-web-thinking-tail-scroll.md | 2 +- .../2026-08-02-web-thinking-tail-scroll.zh.md | 2 +- ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 2 +- ...026-08-08-web-background-job-display.zh.md | 2 +- apps/web/tests/README.i18n.yaml | 4 +- apps/web/tests/README.md | 11 +- apps/web/tests/README.zh.md | 8 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 5 +- docs/config-catalog.zh.md | 5 +- .../adding-a-conversation-node.i18n.yaml | 4 +- docs/cookbook/adding-a-conversation-node.md | 15 +- .../cookbook/adding-a-conversation-node.zh.md | 15 +- .../cookbook/adding-a-settings-card.i18n.yaml | 4 +- docs/cookbook/adding-a-settings-card.md | 2 +- docs/cookbook/adding-a-settings-card.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 602 +++++++------ docs/module-graph.zh.md | 602 +++++++------ docs/subsystems/user-questions.i18n.yaml | 4 +- docs/subsystems/user-questions.md | 34 +- docs/subsystems/user-questions.zh.md | 34 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/remotes/README.i18n.yaml | 2 +- packages/client/AGENTS.md | 2 +- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 5 +- packages/client/README.zh.md | 5 +- packages/client/store/README.i18n.yaml | 6 + packages/client/store/README.md | 17 + packages/client/store/README.zh.md | 17 + packages/client/ui-approval/README.i18n.yaml | 6 + packages/client/ui-approval/README.md | 17 + packages/client/ui-approval/README.zh.md | 17 + .../tests/ui-approval.client.spec.tsx | 17 + packages/client/ui-chat/README.i18n.yaml | 6 + packages/client/ui-chat/README.md | 17 + packages/client/ui-chat/README.zh.md | 17 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 21 +- packages/client/ui-conversation/README.zh.md | 19 +- .../ui-conversation/src/client/apply.ts | 1 + packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 2 +- packages/client/ui-jobs/README.zh.md | 2 +- packages/client/ui-session/README.i18n.yaml | 6 + packages/client/ui-session/README.md | 17 + packages/client/ui-session/README.zh.md | 17 + .../client/ui-session/src/client/index.ts | 18 +- .../tests/ui-session.client.spec.ts | 44 +- .../client/ui-sidebar/src/client/index.ts | 2 + packages/client/ui-slots/README.i18n.yaml | 4 +- packages/client/ui-slots/README.md | 2 +- packages/client/ui-slots/README.zh.md | 2 +- .../tests/browser-plugin.client.spec.ts | 1 + .../tests/workflow-run.client.spec.tsx | 2 +- .../ui-workspace/tests/tree.client.spec.ts | 13 + .../src/client/slot-catalog.ts | 835 +++++++++++------- .../extensions/tool-cordis/src/api-catalog.ts | 36 +- .../interaction/user-questions/src/index.ts | 1 + .../tests/runtime.client.spec.tsx | 2 + packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/registry/README.i18n.yaml | 4 +- scripts/gen-cordis-catalog.ts | 2 + scripts/package-graph.spec.ts | 51 ++ scripts/package-graph.ts | 85 +- scripts/run-oxlint.spec.ts | 2 +- scripts/run-oxlint.ts | 2 +- scripts/type-equiv.manifest.json | 10 +- 97 files changed, 2256 insertions(+), 1048 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md create mode 100644 packages/client/store/README.i18n.yaml create mode 100644 packages/client/store/README.md create mode 100644 packages/client/store/README.zh.md create mode 100644 packages/client/ui-approval/README.i18n.yaml create mode 100644 packages/client/ui-approval/README.md create mode 100644 packages/client/ui-approval/README.zh.md create mode 100644 packages/client/ui-chat/README.i18n.yaml create mode 100644 packages/client/ui-chat/README.md create mode 100644 packages/client/ui-chat/README.zh.md create mode 100644 packages/client/ui-session/README.i18n.yaml create mode 100644 packages/client/ui-session/README.md create mode 100644 packages/client/ui-session/README.zh.md create mode 100644 scripts/package-graph.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 9ee365f446..b80381f031 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2 -2026-07-19-gui-web-client-architecture.zh.md: 705b1337dd97ac37bd01bdcc5aa22484b7971908 +2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 +2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 8b4f940299..4448fd5c68 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -48,7 +48,7 @@ There is no component registration model besides slots — the former view and t **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). -## The data object layer (`packages/client/runtime/src/client/sessions/`) +## The data object layer (`packages/api/session-controller/src/client/`) Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 705b1337dd..e8a8121a1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -48,7 +48,7 @@ slot 之外不存在第二种组件注册模型——原视图环与工具环都 **scope 寻址**与 host 侧 agent(智能体)scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 -## 数据对象层(`packages/client/runtime/src/client/sessions/`) +## 数据对象层(`packages/api/session-controller/src/client/`) 帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index 7a63fc4c41..c32026ebc7 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.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 .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8 -2026-08-08-client-tool-presentation-ownership.zh.md: 181c57a0da61795292d70b3d37ebd1485832795b +2026-08-08-client-tool-presentation-ownership.md: 1daad1559a6c8ef15fadb8e7c8dfeb2874ae3f9a +2026-08-08-client-tool-presentation-ownership.zh.md: f980db28e1174aa95b29defb8b0a36fc0ba4cf2e diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index 3feefc3cfb..1daad1559a 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -16,7 +16,7 @@ Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-u Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names. -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index 181c57a0da..f980db28e1 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -16,7 +16,7 @@ Client 运行时已经按 `callId` 配对工具调用/结果事件,并能从 C Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.zh.md)。`ui-conversation` 的工具 Definition 从会话事件配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方工具 identity 和拓扑,不解释具体工具名称的展示。 -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 业务工具插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取会话、上下文或 Conversation assembler。skill(技能)仍是普通工具;它和其他业务工具使用同一 keyed slot 注册路径。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 90160da7db..e7aa813ad5 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 69f92b906e46ae881b7aa6b5e46e998047fca8c2 -2026-08-09-client-conversation-node-assembly.zh.md: d075e009d9a04f20dbc8dda518e90b368b54286f +2026-08-09-client-conversation-node-assembly.md: e6c0e790a361265870a04ee63301b9f11940c648 +2026-08-09-client-conversation-node-assembly.zh.md: 702ddba0019e125d3976727f841db775276b3b77 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 69f92b906e..e6c0e790a3 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -33,7 +33,7 @@ Registry contributions are Cordis effects. Removing a Definition causes a low-fr ### Overall `ConversationNodeDefinition` contract -Each [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs. +Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs. One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`. @@ -160,7 +160,7 @@ IDs are never reused. Completed Contexts remain in the current window, providing ### Location is a first-class engine fact -[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. +[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events. @@ -302,19 +302,19 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface ## View Builder and React identity -[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions. -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value. For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys. -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. -[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins still live in `ui-conversation`, but this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. +[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins live in `ui-chat`, and this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index d075e009d9..702ddba001 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -33,7 +33,7 @@ Registry 注册是 Cordis effect,Definition 卸载会触发现有 Session 的 ### `ConversationNodeDefinition` 总体契约 -每个 [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 +每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。 @@ -160,7 +160,7 @@ ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定 ### Location 是一级引擎事实 -[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 +[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/Step 各自带 `open`、`closed` 或 `unknown` 状态,以及已加载的 start/end Event。 @@ -302,19 +302,19 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何 ## View Builder 与 React identity -[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。 Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。 -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 -[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现仍位于 `ui-conversation`,但该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 +[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现位于 `ui-chat`,且该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 `conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slot;Slot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml index 50cdedbe5b..d9823a5e88 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.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 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md -2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0 -2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c +2026-08-10-cancelled-stream-prefix-finalize.md: fd397a02663908f5984b4e1798d1b1759b140c79 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: 44adb2ff4163cd1904a9a93895c99519bae2f234 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md index 0cae25b786..fd397a0266 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -36,4 +36,4 @@ Terminal provider errors still discard their streamed prefix. That asymmetry rem ## Testing -`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. +`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md index e961ea6a51..44adb2ff41 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -36,4 +36,4 @@ Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted` ## Testing -`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 +`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml new file mode 100644 index 0000000000..43a002ec74 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.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 .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +2026-08-20-client-session-conversation-ownership.md: e7ab737c13721c41d9244c7830c74cf56ea33f54 +2026-08-20-client-session-conversation-ownership.zh.md: deeca51740cf07e15192744c027e7bc09369bdfa diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md new file mode 100644 index 0000000000..e7ab737c13 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -0,0 +1,461 @@ +# Agent Note: Client Session, Conversation, and UI ownership layers + +Status: implemented + +English | [中文](2026-08-20-client-session-conversation-ownership.zh.md) + +## Problem + +The Web Client once placed Session and Workspace objects, event windows, Conversation assembly, React hooks, the Slot registry, and the Store engine in one general Runtime. Protocol state, business projections, React bindings, and page presentation shared one dependency hub, so a change in any layer could spread across the entire frontend. + +Session snapshots could also accumulate data they did not own, including event arrays, Conversation Views, Chat Nodes, and pending interactions. Ordinary consumers then had to understand event replay and concrete views, while adding a Conversation target could require changes to Session, Runtime, and the renderer. + +Without an explicit interface between React and Session lifetimes, binding release, Hook source replacement, and Slot store cleanup became dedicated callback protocols. Approval and Question both affect sidebar state and composer takeover; independently maintained state could make those surfaces select different pending requests. + +The Client needs one-way dependencies between data owners, React adapters, generic rendering machinery, and concrete views while preserving application behavior. + +## Decision + +The Client uses the layering “Controller and domain object → UI adapter → renderer → Slot component.” Controllers and domain objects publish React-free observable sources; their `ui-*` packages declare standard props and register sources; `ui-renderer` creates selector hooks at Slot binding points; components read data and actions only from Slot props. + +```text +[Remote / Controller / domain object] + | + | bare observable source + v + [ui-* adapter] + | + | standard source registration + v + [ui-renderer] + | + | selector hook binding + v + [Slot component] +``` + +Client Session and Workspace objects belong to `api/session-controller/client` and `api/workspace-controller/client`, respectively. Target-neutral Conversation data structures and assembly belong to `client/ui-conversation`; Chat and Trajectory belong to `client/ui-chat` and `client/ui-trajectory`, respectively. + +The React adapters for Session and Workspace belong to `client/ui-session` and `client/ui-workspace`. The Store engine belongs to `client/store`; the Slot registry, scope materialization, and observable-to-hook binding belong to `client/ui-renderer`. + +The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines Session history, Remote streams, pagination cursors, and reconnect continuity; this note starts from the Client objects and sources published by Controllers. + +## Layering principles + +### Controllers are React-free logic owners + +A Controller may be installed as a Cordis service, but it does not own React Contexts, React hooks, Slot props, or components. A Controller snapshot contains only facts that it owns, and its commands change only Host or domain-object state. + +The UI layer may read multiple Controllers for one navigation decision, but it does not write the combined result back into any Controller snapshot. A UI adapter does not duplicate a Controller command's business implementation. + +### UI adapters own React integration + +Each standard hook belongs to the `ui-*` package closest to its data semantics. + +| Hook | Owner | Source | +| --- | --- | --- | +| `useSessions` | `client/ui-session` | Session Controller global list | +| `useSession` | `client/ui-session` | Current Session snapshot | +| `useProjection` | `client/ui-session` | Current Session keyed projection | +| `useSessionPendingInteraction` | `client/ui-session` | Aggregated pending domains | +| `useWorkspaces` | `client/ui-workspace` | Workspace Controller list | +| `useConversation` | `client/ui-conversation` | Conversation binding snapshot | +| `useChat` | `client/ui-chat` | `chat` target source | +| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source | + +`ui-renderer` implements only generic binding. It does not import Session, Workspace, Conversation, Chat, or Trajectory business types or values. + +### Slot scopes and standard props are separate + +`ui-slots` declares root, session, and session-maybe scopes plus declaration-merge-extensible standard prop types. It does not decide which hooks each scope installs. + +`ui-renderer` implements generic scope adapters and source materialization. `ui-session` installs the Session scope and supplies its built-in sources; other domain packages register only their own sources and the Slot entries that consume them. + +Adding a target does not add a branch to the renderer or Session Controller. The data owner handles state identity, updates, errors, and release; the UI adapter owns the hook; the presentation owner owns target-specific projections and interaction state. + +## Package ownership + +| Package | Owns | Explicitly does not own | +| --- | --- | --- | +| `api/session-controller/client` | Session objects, list, selection, commands, projections, queue, event windows, and Agent Contexts | Conversation targets, React, Slots, Workspace | +| `api/workspace-controller/client` | Workspace objects, ordering, archive state, commands, and snapshots | React, Session navigation policy, directory UI | +| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results | +| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data | +| `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots | +| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory | +| `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots | +| `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election | +| `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election | +| `client/store` | React-free Store contract and implementation | Domain objects, React hooks, Slot lifetimes | +| `client/ui-renderer` | SlotRegistry, scope binding, selector hooks, outlets, and React root | Session, Workspace, and Conversation business logic | + +## Overall data flow + +Session data reaches the UI through this path: + +```text +[ctx.remote.session] + | + v +[api/session-controller/client] + |-- SessionListState --------------------------> [ui-session] -> useSessions + |-- SessionSnapshot ----------------------------> [ui-session] -> useSession + |-- ProjectionValueSource ----------------------> [ui-session] -> useProjection + `-- per-Session SessionEventSource + | + v + [client/ui-conversation] + | + | assemble + v + ConversationSnapshot ----------------> useConversation + | + |---------+----------| + v v + [ui-chat] [ui-trajectory] + | | + useChat useTrajectory +``` + +Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. For cross-domain navigation, `ui-workspace` temporarily reads the Session Controller and issues a selection or command. + +Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.pendingInteractions` then supplies that same object to Session navigation state and Conversation composer selection. + +## Session Controller Client + +### Scope of SessionSnapshot + +`SessionSnapshot` represents control and lifecycle facts belonging to a Session. It may contain identity, running, removed, blank, subagent address, open phase, history phase, prompt error, agent error, and queue state. + +It does not contain: + +- a raw event array; +- Conversation Views; +- Chat Nodes; +- Trajectory rows; +- pending Approval or Question objects; +- presentation state that requires callers to traverse events. + +Whether a field derives from an event, control frame, or local command does not automatically determine its owner; consumption semantics determine ownership. `composerPhase` depends on both Session lifecycle and Conversation target activity, so `ui-conversation` composes it instead of placing it in `SessionSnapshot`. + +### Three read faces + +The Session Controller exposes three distinct read faces: + +1. The global Session list and current-selection source, used by navigation and `useSessions`. +2. A logical binding for each Session containing `sessionId`, a `SessionSnapshot` source, commands, and projection sources. +3. A Conversation-facing `SessionEventSource` used only by the Conversation assembly core. + +Ordinary UI components do not read `SessionEventSource` directly. `ui-session` does not read private event windows, and the `ui-conversation` core receives neither React bindings nor Slot APIs. + +### SessionEventSource + +`SessionEventSource` exposes a materialized event window, not a transport. + +The window carries ordered `entries`, `hasMore`, a monotonic `revision`, and a `replace | prepend | append` change description. + +Initial open, reconnect, gap repair, and updates whose continuity cannot be proven publish `replace`; history pagination publishes `prepend`; a continuous live event publishes `append`. The Conversation core selects incremental update or complete rebuild from the revision and change. + +`MutableSessionEventSource` is the Session Controller's internal write face. Consumers depend only on the read-only `SessionEventSource`. + +### Session binding lifecycle + +Each Session binding owns a Cordis Context and Fiber. The Session Controller creates and releases the binding. + +Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Releasing a binding cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol. + +This cleanup does not require the Session Controller to know the roster of upper-layer consumers. + +## UI Session + +### Service responsibilities + +`client/ui-session` is the sole Session adapter between the Session Controller and the React/Slot system. It provides `ctx.uiSession` and: + +- observes the Session list, current selection, and per-Session bindings; +- installs the session and session-maybe scope adapters; +- supplies `SessionProvider` rendering semantics; +- supplies built-in Session snapshot, projection, and sessionId sources; +- accepts Session-scoped source contributions from other domain packages; +- aggregates pending interactions registered by business packages. + +It does not own Session transport, event folding, Conversation targets, or concrete business results. + +### Standard source registration + +A domain package calls `ctx.uiSession.provide()` to register a bare source. The descriptor statically declares its hook, keyed-hook, and prop rosters; `resolve(binding)` returns exactly those values for one Session binding. For example, `ui-conversation` registers each binding's snapshot as the `conversation` hook source. + +The renderer converts an ordinary source into `use`. Open key spaces such as projections use a keyed-hook resolver, while stable values use props. + +The runtime rejects undeclared, missing, or duplicate standard props. `ui-session` materializes its own built-ins through the same mechanism, so the renderer has no Session-specific name branches. + +### Scope binding + +session and session-maybe use the same adapter with different binding semantics: + +- a strict session scope refuses to render without a current binding; +- session-maybe uses a stable absent binding to preserve hook call order; +- changing the current Session rebuilds the strict Session subtree under the `sessionId` key; +- root and session-maybe entries may remain mounted across Session changes. + +Each real materialized binding retains the Controller binding's Context. `ui-session` removes the cache entry and withdraws the current binding through `binding.ctx.effect()`. + +Changing the contribution roster rematerializes existing bindings and publishes a new source set. Source identity remains stable within one binding lifetime, as required by `useSyncExternalStore` caching. + +### SessionProvider + +`SessionProvider` is a standard seat derived by `PropsRenderSlots` from a session-scoped child declaration, not a React Context imported directly by business components. + +It accepts ordinary `ReactNode` children rather than a `(sessionId) => ReactNode` render function; callers wrap `renderSlot('details', {})` directly. + +Session identity comes from the scope binding and standard `sessionId` prop. The Provider handles only the absent branch and subtree isolation by Session identity; components do not obtain Session data through a Provider callback. + +### Pending interactions + +Business packages extend `SessionPendingInteractionMap` through declaration merging. Every pending object carries at least a stable `key`, domain `kind`, and `sessionId`; `ui-session` does not import concrete Approval or Question types. + +A business plugin calls `registerPendingInteraction(precedence)` in `apply()` to create a stable registration for its pending domain. The returned per-request publication function publishes one exact object and returns an idempotent disposer for that object. + +Concurrent objects with the same key are rejected; replacement requests use a new key. One Session may hold multiple domains or requests at once. + +`ui-session` selects each Session's effective object using domain precedence. Higher precedence wins; at equal precedence, the later valid object in traversal order wins. + +The aggregate is published as `pendingInteractions: ObservableSnapshot>`; `useSessionPendingInteraction` is its React read face. + +Session navigation state and composer takeover read the same effective object. They do not maintain separate status maps or takeover rosters. + +## Workspace Controller and UI Workspace + +### Scope of WorkspaceSnapshot + +`WorkspaceSnapshot` contains only Host-authoritative data owned by the Workspace Controller, including Workspace rows, order, archive set, follow phase, and errors. A Workspace row's `sessionIds` is an association field, not a copy of Session objects in the Workspace snapshot. + +These combined facts do not enter `WorkspaceSnapshot`: + +- whether the Workspace and Session baselines are both ready; +- the most recent Workspace derived from Session update times; +- whether the current Session is cleared because it was archived; +- which blank Session New Session should reuse; +- which Session initial startup should select. + +### UI Workspace composition responsibilities + +`client/ui-workspace` registers the Workspace list source as the root standard source `workspaces`, from which the renderer provides `useWorkspaces`. + +Initial selection, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI navigation policy. That policy may read both `ctx.workspaces` and `ctx.sessions` at decision time, but it issues only Controller commands and selection actions and does not publish a combined snapshot. + +Directory pickers, directory browsing, and `openPath` are separate directory capabilities and do not enter the Workspace Controller. + +## UI Conversation + +### Assembly core + +`client/ui-conversation` contains both the React-free Conversation assembly core and the React adapter for the same domain. + +The core owns `ConversationSnapshot`, the Definition registry, the View registry, the event assembler, the location index, per-Session bindings, target sources, and target activity. + +The core obtains `SessionEventSource` from a Session binding. Append and prepend changes with continuous revisions use incremental assembly; replace changes or revision gaps rebuild from the complete window. + +Definition or View roster changes rebuild only the Conversation binding; they do not rebuild a Session or reopen a Remote stream. The core does not import React and can test event folding, incremental updates, and registry lifetimes independently. + +`ConversationSnapshot` does not copy `SessionSnapshot` or expose raw events. It publishes only the target-neutral View roster, target activity, and target-source lookup. + +`useSession` and `useConversation` come from separate sources and are not guaranteed to publish atomically in one React commit. Components that read both compute purely from their current snapshots and do not treat notification order as business causality. + +### Definition and View registries + +`UiConversation.events` is the sole registry for event Definitions, and `UiConversation.views` is the sole registry for target snapshot builders. + +The registries reject duplicate keys, preserve registration order, and return idempotent disposers. Existing Conversation bindings rebuild from their current event windows when a roster changes. + +A target package extends snapshot and location-data maps through declaration merging, then registers its Definitions, builder, and View. Registrations follow Cordis effect disposal. + +`ui-conversation` does not import concrete target packages. + +### Conversation React adapter + +The React adapter registers each Conversation binding snapshot as the Session standard source `conversation`, from which the renderer provides `useConversation`. + +The package also owns the shell, input, composer chain, queue UI, drafts, View navigation, and phase composition. The core reads no React Context, Slot props, or component state. + +View selection order is a valid persisted selection, registered `chat`, then no View. An invalid selection does not overwrite the persisted value, and the system does not fall back to the first registered View. + +Without `ui-chat`, the shell can still activate and mount but does not implicitly select Trajectory or another target. + +The shell phase is a pure composition of Session lifecycle and Conversation target activity. An active Session or any target reporting visible content produces active; a failed first prompt remains engaging. + +### Input and composer + +The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads the current Session's effective object through `useSessionPendingInteraction` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`. + +A selector is a pure function of owner currency. Its non-null result reaches the selected component as `matched`. A stable composer entry and the default composer remain mounted together, while the chain selects one effective presentation. + +Draft and input state belong to Conversation UI and do not enter the Session snapshot. Queue commands use a Session-scoped service for addressing and do not write queue UI into the Conversation core. + +## Chat and Trajectory targets + +### Chat owner + +`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache. + +It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`. + +Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation. + +The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input. + +### Trajectory owner + +`client/ui-trajectory` registers `trajectory` through the same target protocol. It owns event-record projection, timelines, virtual rows, selection, and the inspection view, and exposes `useTrajectory` through a standard source. + +Session lifecycle reads `useSession`, while Trajectory data reads `useTrajectory`. Trajectory does not obtain its own data through a Session or Chat snapshot. + +Other targets use the same registration flow without modifying the renderer, Session Controller, or ui-session. + +## Approval and Question + +### Stable registration + +Approval and Question plugin installation separates stable registrations from per-request handling. `apply()` registers locale data, calls `registerPendingInteraction()` once for its pending domain, and registers one stable entry in `conversation.composer`. + +The stable Approval entry also declares its detail child Slot. Concurrent requests and Session count do not add composer entries or redeclare Slots, and every registration follows plugin-fiber disposal. + +### One waterfall request + +A Remote Event listener resolves the Session from its own Agent Context. Without a Session scope it calls `next()` to continue the waterfall; with a Session scope it creates a `PendingApproval` or `PendingQuestion`. + +The listener publishes the object through the registered domain publication function, waits for user completion, cancellation, or request-signal abortion, and removes the exact object in `finally`. + +One request does not register a Slot, create another lifecycle effect, or mutate the Session snapshot. + +Approval exposes allow and reject; Question exposes answer and cancel. User cancellation of a Question returns `ASK_CANCELLED`; interruption of a pending request by `AbortSignal` returns `UserQuestionError(ASK_ABORTED)` rather than leaking the carrier's `AbortError` or an ordinary `Error`. + +The Gateway requires only that Remote Event arguments and results are valid JSON transport values. It does not duplicate domain validation of Question options. + +### One pending projection + +The Sidebar and composer consume the same `pendingInteractions` snapshot. Navigation displays approval, plan-review, or question state from the effective object's `kind`; each composer entry selects its own panel by object identity. + +The same request identity drives both UI surfaces. A request that replaces another request of the same type uses a new key, so selectors and subscribers observe the identity change. + +`ui-session` implements only cross-domain precedence and does not interpret Approval or Question fields. + +## UI Renderer and Store + +### UI Renderer + +`client/ui-renderer` owns the `SlotRegistry` service and React renderer. It is responsible for: + +- `ctx.slots.register()`, `inject()`, `renderSlot()`, and declaration lifetimes; +- root, session, and session-maybe scope adapters; +- binding standard observable sources to selector hooks; +- Slot outlets, error isolation, root mount, and hydration; +- managing Slot store instance lifetimes by scope key. + +The renderer may know generic scope names and binding protocols but does not read domain services. Rendering Session scope without an installed adapter is an assembly error that fails immediately. + +### Store + +`client/store` is a plain React-free library owning `ObservableSnapshot`, `SnapshotStore`, `defineStore`, `createSnapshotStore`, and `shallowEqual`. + +`ui-slots` references the Store contract; `ui-renderer` manages Store instances and supplies `useStore`. + +Stores hold viewing and interaction state such as drafts, View selection, Chat selection, inspection requests, and panel size. Session, Workspace, Conversation, Remote streams, and connection generations do not enter Stores. + +### Registration and release order + +When one plugin provides both a source and a Slot entry, it registers the source first and the entry second. Reverse Cordis disposal then removes the entry before the source, so a mounted entry never briefly loses a required hook. + +Releasing a Session binding cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers. + +Every disposer is idempotent and depends on no implicit callback outside the Cordis lifecycle. + +## Composition and dependency direction + +The application bundle explicitly installs the required Controller, adapter, target, and renderer plugins. Each owner's `apply()` installs only its own service, listener, and contributions. + +Runtime consumption flows as `session-controller → ui-session → ui-conversation → target UI`, `workspace-controller → ui-workspace`, and `store → ui-slots → ui-renderer`; Approval and Question depend only on the pending-registration point exposed by `ui-session`. + +Arrows in this description represent runtime consumption and do not include type-only declaration-merge edges. Controllers do not depend back on UI adapters, the renderer does not depend back on domain packages, and the Conversation core does not depend on a concrete target. + +UI components do not receive `ctx`. Cross-package collaboration uses Cordis services, standard sources, or Slot registrations without introducing an aggregate facade. + +## Developer guidance + +### Choose the data owner first + +Before adding state, choose its sole owner from its consumption semantics: Host communication, commands, and entity lifecycle belong to an API Controller; data assembled from Session events but independent of a target belongs to the Conversation core; projections serving only one View belong to that target package; drafts, selections, and panel state belong to the UI package that owns the interaction. + +The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. A cross-domain decision reads multiple sources and immediately issues a command; it does not create a joined snapshot or cache another domain's object. + +These are signs of incorrect ownership: a Controller imports React; the renderer branches on business types; a component traverses Session events; a Store holds Session or Workspace entities; changing one target requires changing the Session Controller. + +### Add Session-scoped data + +1. Provide a React-free observable source in the domain owner. +2. Declaration-merge the standard prop type in the owning UI adapter. +3. Declare a fixed roster through `ctx.uiSession.provide()` and resolve its source from a Session binding. +4. Let the Slot component receive the generated hook through `PropsRuntime`; do not pass `ctx` to a component. +5. Attach each binding resource's cleanup to `binding.ctx.effect()` and leave registration cleanup to the plugin fiber. +6. Test missing values, duplicate names, roster replacement, Session changes, and binding disposal. + +Only open key spaces use keyed hooks. Finite stable sources use ordinary hooks, and immutable identifiers use props. Do not hard-code business names in the renderer to save one registration. + +### Add a Conversation target + +1. Extend the Conversation snapshot or location-data map in the target package. +2. Register the required event Definitions with `UiConversation.events`. +3. Register the snapshot builder, target id, View, and activity rule with `UiConversation.views`. +4. Expose the target's standard selector hook through `ctx.uiSession.provide()`. +5. Register the renderer, locale, and target-specific Slot entries in the same package. +6. Verify that unloading the target rebuilds only the Conversation binding without changing the Session, other targets, or Remote stream. + +A target must not use another target's snapshot as its data source. Optional collaboration uses a narrow port or Slot; when a target is absent, the shell remains bootable and does not guess a fallback. + +### Add a pending-interaction domain + +1. Define the Pending object and its completion, cancellation, and interruption semantics in the business package. +2. Add the object to `SessionPendingInteractionMap` through declaration merging. +3. Call `registerPendingInteraction()` once in `apply()` and register one stable composer entry. +4. Resolve the Session from the Agent Context in the Remote waterfall listener; call `next()` when the listener cannot handle the request. +5. When it can handle the request, create the Pending object, publish it through the publication function, await its result, and remove it in `finally`. +6. Test concurrent keys, precedence, user cancellation, transport abort, plugin disposal, and delegation without a Session. + +A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer both read one effective object from `useSessionPendingInteraction`. + +### Review checks + +- Every new source, registry contribution, listener, and cache has an explicit Cordis-fiber or Session-binding owner. +- Every public hook traces to one React-free source; no selector is forwarded through layers only to pass arguments. +- Every component obtains data and actions from standard props or the owning Slot's inject face. +- Every target has defined behavior when absent, dynamically registered, and unloaded. +- Every cross-layer import advances in the one-way Controller, adapter, renderer, component direction. +- Each error is classified by the earliest owner that can explain its semantics; carrier errors do not leak directly as business errors. + +## Verification + +Tests owned by each layer pin Controller bindings and event sources, UI scopes and pending precedence, incremental Conversation assembly and View fallback, target projections, waterfall results, and renderer scope/Store lifetimes. Application-composition tests cover both the complete roster and startup without a concrete target; component tests do not replace object-layer, replay, and lifecycle tests. + +## Alternatives considered + +- **Keep a Runtime facade.** One entry point would retain the dependency hub and let new code bypass domain owners, so the system provides neither the facade nor a compatibility export. +- **Put all Client state in API Controllers.** Protocol objects would then own React, Views, and presentation policy, so Controllers retain only React-free domain state. +- **Let Controllers provide React hooks directly.** Non-React consumers could not reuse the same objects, and transport and renderer lifetimes would become interdependent. +- **Put Conversation in SessionSnapshot.** This would expand the Session API and force ordinary Session consumers to understand event folding and target rosters. +- **Let Chat and Trajectory replay Session events independently.** Ordering, locations, and registry rebuild would be duplicated, so the shared assembly core stays in `ui-conversation`. +- **Extract the Conversation core into another non-UI package.** The core and adapter currently evolve together and have no other non-UI package consumer; directory separation within one package keeps the core React-free. +- **Combine Workspace and Session into one snapshot.** This would create another cross-domain owner, so cross-domain logic remains an immediate decision in `ui-workspace`. +- **Build every standard hook into the renderer.** Generic infrastructure would need to know every domain, so standard-source registration keeps the renderer independent from business types. +- **Dynamically register a composer entry for every pending request.** This would redeclare child Slots and make concurrent requests compete through registration order, so stable entries are separate from request publication. +- **Write pending interactions into a Session projection.** An unanswered waterfall is not a committed durable Session fact; Remote Event replay restores it after refresh, so it remains in a business UI source. +- **Add a dedicated release callback to bindings.** This would duplicate the Cordis lifecycle; `binding.ctx.effect()` already attaches consumer cleanup to the same owner. +- **Pass the Session id from SessionProvider through a render function.** This would create another data-injection path; ordinary children and the standard `sessionId` prop retain one entry for scoped data. +- **Keep Store in the renderer.** The Store contract does not depend on React and is reused by objects and test infrastructure, so `client/store` keeps the engine separate from rendering lifetimes. + +## Consequences + +Session, Workspace, Conversation, and each concrete target own one authoritative state. Non-React consumers can reuse Controllers and the assembly core directly. A new Conversation target registers its Definition, builder, View, standard source, and Slot entries; a new pending-interaction domain declares its type, registers its domain, and provides one stable composer entry. + +The renderer and Session Controller gain no branch for a new business domain, while Session bindings and plugin fibers provide two explicit, composable release paths. The UI can observe independent Session and Conversation source publications, and consumers cannot depend on their notification order. + +Composition packages must explicitly load the required adapter and target plugins. The shell remains operational without a concrete target but neither creates nor guesses that target's View. More packages and explicit registrations add assembly work, while dependency direction, test scope, and failure ownership become locally identifiable. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 6db4668e86..deb8efc160 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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 .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: e4c9fdd6fcdec0b14ed2724dd58365e1d98af8b4 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 625bd993a0b20bbbed318edf71d79866fa80bff0 +2026-07-30-web-transcript-log-ordered-projection.md: ed6fa4df3ac25bf6fe2947e6fb7bb7cbde6ac00a +2026-07-30-web-transcript-log-ordered-projection.zh.md: cf8dc35099f416356310081f758d0b89fe8282b9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index e4c9fdd6fc..ed6fa4df3a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' ``` -Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. +Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/ui-chat/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. +`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 625bd993a0..cf8dc35099 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' ``` -重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 +重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/ui-chat/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 +`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml index 9f352def8f..a148348ba4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.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 .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md -2026-08-05-workspace-blank-session-reuse-membership.md: 0d46a0ccf6924c508db2e6c0f3591468e2956722 -2026-08-05-workspace-blank-session-reuse-membership.zh.md: 6350c6773265edebb381d5ca10fd5126a5722a5a +2026-08-05-workspace-blank-session-reuse-membership.md: df8cc898f5a80937b8aac69ebd51a6a93882971b +2026-08-05-workspace-blank-session-reuse-membership.zh.md: 73ccdbbb002920eea8f7b01bc0fa8a18859bd3ee diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md index 0d46a0ccf6..df8cc898f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -26,4 +26,4 @@ Stray blank sessions remain visible in Ungrouped (the user can still open them) ## Testing -`packages/client/runtime/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. +`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md index 6350c67732..73ccdbbb00 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 测试 -`packages/client/runtime/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 +`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml index a28a57cfd5..841f1c8442 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.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 .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md -2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 66a1ca361cf28bf0beab95fa81da9cac3527474c -2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 6aa1866e802b00d0a3431dfd5bef9efd57121294 +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: b55bbc39f6e1f24bb7751b7743da23736abbd06b +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: e38ead6c9b80e41b37556172bd6c1f411858b6ed diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md index 66a1ca361c..b55bbc39f6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the ## Testing -Unit specs in `packages/client/ui-conversation/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. +Unit specs in `packages/client/ui-chat/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md index 6aa1866e80..e38ead6c9b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输 ## 测试 -`packages/client/ui-conversation/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 +`packages/client/ui-chat/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index a20606b5ed..6cc0c25b8e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.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 .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: b9aa47a01b4d8e22baddac1b03f52b3524250941 -2026-08-02-web-thinking-tail-scroll.zh.md: 41fe29f06202aef3307d756201eb4745fecca537 +2026-08-02-web-thinking-tail-scroll.md: 38c27274b2c85974044c2bb467c1519e19bfd148 +2026-08-02-web-thinking-tail-scroll.zh.md: bf637228b3a793f318fa5a5d7b0968ecd8e081d5 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index b9aa47a01b..38c27274b2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index 41fe29f062..bf637228b3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 +`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 2ae59b9806..1ee7b9dfc3 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.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 .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 962d29e35ffe436c5ab91307d0cdfa557bbd539f -2026-08-08-web-background-job-display.zh.md: 8b391c272b8ff38028cc6819f2fbec49505d85aa +2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27 +2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 962d29e35f..8da6c2fd91 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -101,7 +101,7 @@ A running one-shot background subagent therefore appears both there and in the s ## Alternatives considered -**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. +**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. **Popover-scoped polling with no seam change.** Cheapest to build and the only option that avoids touching `JobRegistry`. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 8b391c272b..0e05ef9d2f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -101,7 +101,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ## 备选方案 -**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 +**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 **只在弹层打开时轮询,不改 seam。** 最省事,也是唯一不碰 `JobRegistry` 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。 diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 5daf55b021..3c260eab76 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.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 apps/web/tests/README.md -README.md: acb0c300bafe221f6a92f0168908bebf965377b9 -README.zh.md: 029f3190bb89bede5506d3d58f5e6df229218493 +README.md: 2104d9422cfbbcbc7ffc4b12e491c0a62daa3b9d +README.zh.md: 4dfa5b2f61c757e481d9b8e012b37a5e71d75093 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index acb0c300ba..2104d9422c 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -33,14 +33,11 @@ then surfaces as a missed selector or a stale mirrored value — a loud failure, never a silent pass. `scaffold.ts` follows this rule for the welcome-notice namespace, acknowledgement field, version, and asserted Chinese copy. -Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so +One kind of Client import stands. `assembled-boot.ts` drives the shell itself, so it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what -that harness is for, and both packages are already in the Host graph. Separately, -the chat scenarios import `conversationContextKey` from -`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable -through the unsplit `directory-picker` packages and pulls nothing further in. -That reachability is incidental, not a guarantee — if it ever leaves the graph, -mirror the helper like the rest. +that harness is for, and both packages are already in the Host graph. The chat +scenarios mirror `conversationContextKey` in `support.ts` instead of importing +its Client owner. Nothing mechanically enforces this rule; keep it in review. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 029f3190bb..4dfa5b2f61 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -26,12 +26,10 @@ Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/re import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默 通过。`scaffold.ts` 按此规则镜像欢迎声明的 namespace、确认字段、版本和被断言的中文文案。 -有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 +有一类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 `@deepseek-ai/dsh-client-web` import `AppWebEntry`、从 `@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该 -harness 的用途,且这两个包本来就在 Host 图中。另外,chat 场景从 -`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为 -`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是 -偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。 +harness 的用途,且这两个包本来就在 Host 图中。chat 场景则在 `support.ts` 中镜像 +`conversationContextKey`,而不 import 其 Client owner。 没有任何机制强制这条规则;靠 review 守住它。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 5c6e297fc2..bdf326f8db 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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 docs/api-gateway.md -api-gateway.md: cd3103a172d75a4ab325a2368e37af354f09051b -api-gateway.zh.md: fd7494917f209af4a16f88b87afbc47d75c6afd3 +api-gateway.md: 60b9893675ad965c3f88677eac32352acfffeb31 +api-gateway.zh.md: fc217ce3a976fd8cf045848aa331c2115c3a4d65 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index cd3103a172..60b9893675 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -59,7 +59,7 @@ The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { AgentContext } from '@deepseek-ai/dsh-api-session-controller/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index fd7494917f..fc217ce3a9 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -59,7 +59,7 @@ Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直 ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { AgentContext } from '@deepseek-ai/dsh-api-session-controller/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6c0a1a7e11..c9e8aa0367 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: e83d794302e7fbcf84cb5c1672821e59842e2b28 -config-catalog.zh.md: 406dbb3c77527317332b48cf513909c56a9b8a3b +config-catalog.md: 7636b1e3c7933f6d70a8e40d761b3617e746c13d +config-catalog.zh.md: 4567f09059a05d3d87f336caa55fa7db831145af diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e83d794302..7636b1e3c7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3244,10 +3244,11 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-brand-official` ([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-chat` ([`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts)) - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) @@ -3264,6 +3265,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer` ([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-session` ([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) @@ -3344,6 +3346,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 406dbb3c77..4567f09059 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3246,10 +3246,11 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-brand-official`([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-chat`([`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts)) - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) @@ -3266,6 +3267,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer`([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-session`([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) @@ -3345,6 +3347,7 @@ export interface Config { - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base`([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives`([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) diff --git a/docs/cookbook/adding-a-conversation-node.i18n.yaml b/docs/cookbook/adding-a-conversation-node.i18n.yaml index e06b41a788..e51fcf65c5 100644 --- a/docs/cookbook/adding-a-conversation-node.i18n.yaml +++ b/docs/cookbook/adding-a-conversation-node.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 docs/cookbook/adding-a-conversation-node.md -adding-a-conversation-node.md: c1965dc8a3081eebb8c1026ac53d2f7b8964edb7 -adding-a-conversation-node.zh.md: 2986f695b351cd17637d7ff99112c38042950692 +adding-a-conversation-node.md: daa86f90473cb7023a21e1cfa25339fcd79aa558 +adding-a-conversation-node.zh.md: 8c6360a99acb49aebadf9a28f440853b18514b9a diff --git a/docs/cookbook/adding-a-conversation-node.md b/docs/cookbook/adding-a-conversation-node.md index c1965dc8a3..daa86f9047 100644 --- a/docs/cookbook/adding-a-conversation-node.md +++ b/docs/cookbook/adding-a-conversation-node.md @@ -28,12 +28,13 @@ The example keeps the producer declarations and client contribution in one block ```ts ignore-check import { createElement } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' import type { - ClientContext, ConversationLocation, ConversationNodeContext, + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-chat/client' type ReviewId = Branded<'ReviewId'> @@ -94,7 +95,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { } } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { 'review-job': ReviewChatData } @@ -182,10 +183,10 @@ function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { return createElement('p', null, text) } -export const inject = ['conversationEvents', 'slots'] +export const inject = ['uiConversation', 'slots'] export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(reviewDefinition) + ctx.uiConversation.events.register(reviewDefinition) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'review-job', @@ -230,4 +231,4 @@ Add focused tests that establish these outcomes: 5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested. 6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes. -Use [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. +Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. diff --git a/docs/cookbook/adding-a-conversation-node.zh.md b/docs/cookbook/adding-a-conversation-node.zh.md index 2986f695b3..8c6360a99a 100644 --- a/docs/cookbook/adding-a-conversation-node.zh.md +++ b/docs/cookbook/adding-a-conversation-node.zh.md @@ -28,12 +28,13 @@ ```ts ignore-check import { createElement } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' import type { - ClientContext, ConversationLocation, ConversationNodeContext, + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-chat/client' type ReviewId = Branded<'ReviewId'> @@ -94,7 +95,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { } } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { 'review-job': ReviewChatData } @@ -182,10 +183,10 @@ function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { return createElement('p', null, text) } -export const inject = ['conversationEvents', 'slots'] +export const inject = ['uiConversation', 'slots'] export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(reviewDefinition) + ctx.uiConversation.events.register(reviewDefinition) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'review-job', @@ -230,4 +231,4 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。 -流式与中断处理可参考 [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 +流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml index 1ebdaf5789..d4411c819b 100644 --- a/docs/cookbook/adding-a-settings-card.i18n.yaml +++ b/docs/cookbook/adding-a-settings-card.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 docs/cookbook/adding-a-settings-card.md -adding-a-settings-card.md: 8fc0f63dfaa932adba25caf1504cfd8cf28c7460 -adding-a-settings-card.zh.md: 956af879b1c1fcbb018b9420f31b2ae11244b415 +adding-a-settings-card.md: 6035cc3c586cd319c89fad95c12610d35742708c +adding-a-settings-card.zh.md: 79a4372c24f53e3d31b165d9300af591987da1f6 diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md index 8fc0f63dfa..6035cc3c58 100644 --- a/docs/cookbook/adding-a-settings-card.md +++ b/docs/cookbook/adding-a-settings-card.md @@ -48,7 +48,7 @@ export function apply(ctx: Context, config: Config) { The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read: ```ts ignore-check -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: the keyed slot's declaration. Cross-plugin collaboration goes // through cordis services; a value import fails the client bundle-purity gate. import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md index 956af879b1..79a4372c24 100644 --- a/docs/cookbook/adding-a-settings-card.zh.md +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -48,7 +48,7 @@ export function apply(ctx: Context, config: Config) { 卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅: ```ts ignore-check -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: the keyed slot's declaration. Cross-plugin collaboration goes // through cordis services; a value import fails the client bundle-purity gate. import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 0dfc790546..6280fbe004 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: ab49462eac55dec981c4a5951a7eb0bfb28b08f0 -module-graph.zh.md: cf0f12f71fd59370059c5a875dfcaa6c7ae4b4ba +module-graph.md: 9bbbb81dc1e5b4aa65619702d380eeb126cfdea3 +module-graph.zh.md: 86298ce1ed68aa550f5b0b489ee6dccd49771638 diff --git a/docs/module-graph.md b/docs/module-graph.md index ab49462eac..9bbbb81dc1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -129,10 +129,12 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] - pkg_client_runtime["client-runtime"] + pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] + pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -149,6 +151,7 @@ flowchart TD pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] + pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -362,7 +365,9 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants + pkg_client_ui_renderer --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_web --> pkg_invariants pkg_code_runtime --> pkg_invariants @@ -1236,6 +1241,7 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_invariants pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1252,6 +1258,7 @@ flowchart TD pkg_api_session_controller --> pkg_typert_registry pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1275,272 +1282,352 @@ flowchart TD pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_user_approval pkg_api_remotes --> pkg_user_questions - pkg_client_runtime --> pkg_agent - pkg_client_runtime --> pkg_api_gateway - pkg_client_runtime --> pkg_api_remotes - pkg_client_runtime --> pkg_api_session_controller - pkg_client_runtime --> pkg_api_workspace_controller - pkg_client_runtime --> pkg_attachment - pkg_client_runtime --> pkg_client_connection - pkg_client_runtime --> pkg_commands - pkg_client_runtime --> pkg_host_apiproxy - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_llm - pkg_client_runtime --> pkg_llm_retry - pkg_client_runtime --> pkg_session - pkg_client_runtime --> pkg_session_projection - pkg_client_runtime --> pkg_session_title - pkg_client_runtime --> pkg_tool_todo - pkg_client_runtime --> pkg_tools - pkg_client_runtime --> pkg_typert_protocol - pkg_client_runtime --> pkg_typert_registry - pkg_client_runtime --> pkg_util_crypto - pkg_client_ui_renderer --> pkg_client_runtime - pkg_client_ui_renderer --> pkg_invariants + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection - pkg_client_ui_settings --> pkg_client_runtime pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes pkg_client_locale --> pkg_client_connection - pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_renderer pkg_client_locale --> pkg_client_ui_settings pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_renderer - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_input_trigger --> pkg_client_locale - pkg_client_ui_input_trigger --> pkg_client_runtime - pkg_client_ui_input_trigger --> pkg_file_reference - pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale - pkg_client_ui_settings_models --> pkg_client_runtime + pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_settings_plugin_inventory --> pkg_api_remotes pkg_client_ui_settings_plugin_inventory --> pkg_client_locale - pkg_client_ui_settings_plugin_inventory --> pkg_client_runtime + pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_renderer pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale - pkg_client_ui_settings_plugins --> pkg_client_runtime + pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings pkg_client_ui_settings_plugins --> pkg_invariants pkg_client_ui_theme --> pkg_api_remotes pkg_client_ui_theme --> pkg_client_connection pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_renderer pkg_client_ui_theme --> pkg_client_ui_settings pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_theme --> pkg_settings - pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_renderer + pkg_client_ui_layout --> pkg_client_ui_session pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_cordis_client_runner --> pkg_api_remotes + pkg_cordis_client_runner --> pkg_client_connection + pkg_cordis_client_runner --> pkg_client_modules + pkg_cordis_client_runner --> pkg_client_ui_renderer + pkg_cordis_client_runner --> pkg_client_ui_theme + pkg_cordis_client_runner --> pkg_invariants + pkg_client_ui_conversation --> pkg_api_remotes + pkg_client_ui_conversation --> pkg_api_session_controller + pkg_client_ui_conversation --> pkg_api_workspace_controller + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_ui_layout + pkg_client_ui_conversation --> pkg_client_ui_renderer + pkg_client_ui_conversation --> pkg_client_ui_session + pkg_client_ui_conversation --> pkg_client_ui_settings + pkg_client_ui_conversation --> pkg_client_ui_workspace + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_goal + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_permission_presets + pkg_client_ui_conversation --> pkg_plan_mode + pkg_client_ui_conversation --> pkg_session + pkg_client_ui_conversation --> pkg_settings + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tool_todo + pkg_client_ui_conversation --> pkg_util_crypto + pkg_client_ui_conversation --> pkg_workspace + pkg_client_ui_sidebar --> pkg_api_workspace_controller + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_ui_layout + pkg_client_ui_sidebar --> pkg_client_ui_renderer + pkg_client_ui_sidebar --> pkg_client_ui_session + pkg_client_ui_sidebar --> pkg_client_ui_workspace + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_session_controller + pkg_client_ui_workspace --> pkg_api_workspace_controller + pkg_client_ui_workspace --> pkg_client_connection + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_ui_conversation + pkg_client_ui_workspace --> pkg_client_ui_renderer + pkg_client_ui_workspace --> pkg_client_ui_session + pkg_client_ui_workspace --> pkg_client_ui_sidebar + pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_workspace --> pkg_session + pkg_client_ui_agent_preset --> pkg_api_remotes + pkg_client_ui_agent_preset --> pkg_api_session_controller + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_renderer + pkg_client_ui_agent_preset --> pkg_client_ui_session + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_workspace + pkg_client_ui_agent_preset --> pkg_invariants + pkg_client_ui_agent_preset --> pkg_session + pkg_client_ui_approval --> pkg_api_remotes + pkg_client_ui_approval --> pkg_api_session_controller + pkg_client_ui_approval --> pkg_client_locale + pkg_client_ui_approval --> pkg_client_ui_conversation + pkg_client_ui_approval --> pkg_client_ui_renderer + pkg_client_ui_approval --> pkg_client_ui_session + pkg_client_ui_approval --> pkg_invariants + pkg_client_ui_approval --> pkg_llm + pkg_client_ui_approval --> pkg_session + pkg_client_ui_approval --> pkg_typert_protocol + pkg_client_ui_brand_official --> pkg_client_ui_conversation + pkg_client_ui_brand_official --> pkg_client_ui_renderer + pkg_client_ui_brand_official --> pkg_client_ui_sidebar + pkg_client_ui_brand_official --> pkg_invariants + pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_client_locale + pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_browse --> pkg_invariants + pkg_client_ui_directory_picker_native --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_native --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_api_session_controller + pkg_client_ui_input_trigger --> pkg_client_locale + pkg_client_ui_input_trigger --> pkg_client_ui_conversation + pkg_client_ui_input_trigger --> pkg_client_ui_renderer + pkg_client_ui_input_trigger --> pkg_client_ui_session + pkg_client_ui_input_trigger --> pkg_file_reference + pkg_client_ui_input_trigger --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_session + pkg_client_ui_jobs --> pkg_api_session_controller + pkg_client_ui_jobs --> pkg_client_locale + pkg_client_ui_jobs --> pkg_client_ui_conversation + pkg_client_ui_jobs --> pkg_client_ui_renderer + pkg_client_ui_jobs --> pkg_client_ui_session + pkg_client_ui_jobs --> pkg_invariants + pkg_client_ui_plan --> pkg_api_remotes + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_renderer + pkg_client_ui_plan --> pkg_client_ui_session + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_plan --> pkg_session + pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_ui_renderer + pkg_client_ui_settings_general --> pkg_client_ui_session + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_sidebar + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_ui_conversation + pkg_client_ui_trajectory --> pkg_client_ui_renderer + pkg_client_ui_trajectory --> pkg_client_ui_session + pkg_client_ui_trajectory --> pkg_compaction + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_session + pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_user_questions --> pkg_api_remotes + pkg_client_ui_user_questions --> pkg_api_session_controller + pkg_client_ui_user_questions --> pkg_client_locale + pkg_client_ui_user_questions --> pkg_client_ui_conversation + pkg_client_ui_user_questions --> pkg_client_ui_renderer + pkg_client_ui_user_questions --> pkg_client_ui_session + pkg_client_ui_user_questions --> pkg_invariants + pkg_client_ui_user_questions --> pkg_session + pkg_client_ui_user_questions --> pkg_typert_protocol + pkg_client_ui_user_questions --> pkg_user_questions + pkg_client_ui_chat --> pkg_agent + pkg_client_ui_chat --> pkg_api_remotes + pkg_client_ui_chat --> pkg_api_session_controller + pkg_client_ui_chat --> pkg_api_workspace_controller + pkg_client_ui_chat --> pkg_attachment + pkg_client_ui_chat --> pkg_client_locale + pkg_client_ui_chat --> pkg_client_ui_approval + pkg_client_ui_chat --> pkg_client_ui_conversation + pkg_client_ui_chat --> pkg_client_ui_layout + pkg_client_ui_chat --> pkg_client_ui_renderer + pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_workspace + pkg_client_ui_chat --> pkg_commands + pkg_client_ui_chat --> pkg_compaction + pkg_client_ui_chat --> pkg_invariants + pkg_client_ui_chat --> pkg_llm + pkg_client_ui_chat --> pkg_llm_retry + pkg_client_ui_chat --> pkg_session + pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_token_meter + pkg_client_ui_chat --> pkg_tools + pkg_client_ui_commands --> pkg_api_remotes + pkg_client_ui_commands --> pkg_api_session_controller + pkg_client_ui_commands --> pkg_client_locale + pkg_client_ui_commands --> pkg_client_ui_conversation + pkg_client_ui_commands --> pkg_client_ui_input_trigger + pkg_client_ui_commands --> pkg_client_ui_renderer + pkg_client_ui_commands --> pkg_client_ui_session + pkg_client_ui_commands --> pkg_commands + pkg_client_ui_commands --> pkg_invariants + pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes pkg_client_ui_reference --> pkg_client_locale - pkg_client_ui_reference --> pkg_client_runtime pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol - pkg_cordis_client_runner --> pkg_api_remotes - pkg_cordis_client_runner --> pkg_client_connection - pkg_cordis_client_runner --> pkg_client_modules - pkg_cordis_client_runner --> pkg_client_runtime - pkg_cordis_client_runner --> pkg_client_ui_theme - pkg_cordis_client_runner --> pkg_invariants - pkg_client_ui_conversation --> pkg_agent - pkg_client_ui_conversation --> pkg_api_remotes - pkg_client_ui_conversation --> pkg_attachment - pkg_client_ui_conversation --> pkg_brand - pkg_client_ui_conversation --> pkg_client_connection - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_input_trigger - pkg_client_ui_conversation --> pkg_client_ui_layout - pkg_client_ui_conversation --> pkg_client_ui_settings - pkg_client_ui_conversation --> pkg_commands - pkg_client_ui_conversation --> pkg_compaction - pkg_client_ui_conversation --> pkg_goal - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_llm_retry - pkg_client_ui_conversation --> pkg_permission_presets - pkg_client_ui_conversation --> pkg_plan_mode - pkg_client_ui_conversation --> pkg_session_stats - pkg_client_ui_conversation --> pkg_settings - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_conversation --> pkg_tool_todo - pkg_client_ui_conversation --> pkg_tools - pkg_client_ui_conversation --> pkg_util_crypto - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_layout - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_agent_preset --> pkg_api_remotes - pkg_client_ui_agent_preset --> pkg_client_connection - pkg_client_ui_agent_preset --> pkg_client_locale - pkg_client_ui_agent_preset --> pkg_client_runtime - pkg_client_ui_agent_preset --> pkg_client_ui_conversation - pkg_client_ui_agent_preset --> pkg_client_ui_settings - pkg_client_ui_agent_preset --> pkg_invariants - pkg_client_ui_attachment --> pkg_attachment - pkg_client_ui_attachment --> pkg_client_runtime - pkg_client_ui_attachment --> pkg_client_ui_conversation - pkg_client_ui_attachment --> pkg_invariants - pkg_client_ui_brand_official --> pkg_client_runtime - pkg_client_ui_brand_official --> pkg_client_ui_conversation - pkg_client_ui_brand_official --> pkg_client_ui_sidebar - pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_commands --> pkg_api_remotes - pkg_client_ui_commands --> pkg_client_locale - pkg_client_ui_commands --> pkg_client_runtime - pkg_client_ui_commands --> pkg_client_ui_conversation - pkg_client_ui_commands --> pkg_client_ui_input_trigger - pkg_client_ui_commands --> pkg_commands - pkg_client_ui_commands --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_connection - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_deliverables --> pkg_system_prompt - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_commands - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_goal --> pkg_session - pkg_client_ui_goal --> pkg_typert_protocol - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_runtime - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_api_remotes - pkg_client_ui_message_feedback --> pkg_client_connection - pkg_client_ui_message_feedback --> pkg_client_locale - pkg_client_ui_message_feedback --> pkg_client_runtime - pkg_client_ui_message_feedback --> pkg_client_ui_conversation - pkg_client_ui_message_feedback --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_message_feedback - pkg_client_ui_message_feedback --> pkg_typert_protocol - pkg_client_ui_plan --> pkg_api_remotes - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_settings_general --> pkg_api_remotes - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_sidebar - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_subagent --> pkg_api_session_controller + pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation pkg_client_ui_subagent --> pkg_client_ui_input_trigger + pkg_client_ui_subagent --> pkg_client_ui_renderer + pkg_client_ui_subagent --> pkg_client_ui_session pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_session pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_client_ui_tool --> pkg_api_remotes - pkg_client_ui_tool --> pkg_client_connection - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_invariants - pkg_client_ui_trajectory --> pkg_agent - pkg_client_ui_trajectory --> pkg_client_locale - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_conversation - pkg_client_ui_trajectory --> pkg_compaction - pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_trajectory --> pkg_tools - pkg_client_ui_user_questions --> pkg_api_remotes - pkg_client_ui_user_questions --> pkg_client_locale - pkg_client_ui_user_questions --> pkg_client_runtime - pkg_client_ui_user_questions --> pkg_client_ui_conversation - pkg_client_ui_user_questions --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_client_locale - pkg_client_ui_workflow_run --> pkg_client_runtime - pkg_client_ui_workflow_run --> pkg_client_ui_conversation - pkg_client_ui_workflow_run --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_session - pkg_client_ui_workflow_run --> pkg_tool_workflow - pkg_client_ui_workflow_run --> pkg_workflow - pkg_client_ui_workspace --> pkg_client_connection - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_conversation - pkg_client_ui_workspace --> pkg_client_ui_sidebar - pkg_client_ui_workspace --> pkg_invariants - pkg_session_log_export --> pkg_client_locale - pkg_session_log_export --> pkg_client_runtime - pkg_session_log_export --> pkg_client_ui_commands - pkg_session_log_export --> pkg_client_ui_conversation - pkg_session_log_export --> pkg_commands - pkg_session_log_export --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_locale - pkg_client_ui_directory_picker_browse --> pkg_client_runtime - pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_browse --> pkg_invariants - pkg_client_ui_directory_picker_native --> pkg_client_runtime - pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_native --> pkg_invariants - pkg_client_ui_model_selection --> pkg_api_remotes - pkg_client_ui_model_selection --> pkg_api_session_controller - pkg_client_ui_model_selection --> pkg_client_connection - pkg_client_ui_model_selection --> pkg_client_locale - pkg_client_ui_model_selection --> pkg_client_runtime - pkg_client_ui_model_selection --> pkg_client_ui_commands - pkg_client_ui_model_selection --> pkg_client_ui_conversation - pkg_client_ui_model_selection --> pkg_client_ui_input_trigger - pkg_client_ui_model_selection --> pkg_invariants - pkg_client_ui_model_selection --> pkg_typert_protocol - pkg_client_ui_permission_presets --> pkg_api_remotes - pkg_client_ui_permission_presets --> pkg_client_connection - pkg_client_ui_permission_presets --> pkg_client_locale - pkg_client_ui_permission_presets --> pkg_client_runtime - pkg_client_ui_permission_presets --> pkg_client_ui_commands - pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger - pkg_client_ui_permission_presets --> pkg_client_ui_settings - pkg_client_ui_permission_presets --> pkg_invariants - pkg_client_ui_permission_presets --> pkg_permission_presets - pkg_client_ui_skill --> pkg_api_remotes - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_input_trigger - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_cordis --> pkg_api_remotes - pkg_client_ui_cordis --> pkg_client_connection - pkg_client_ui_cordis --> pkg_client_locale - pkg_client_ui_cordis --> pkg_client_runtime - pkg_client_ui_cordis --> pkg_client_ui_input_trigger - pkg_client_ui_cordis --> pkg_client_ui_sidebar - pkg_client_ui_cordis --> pkg_client_ui_tool - pkg_client_ui_cordis --> pkg_cordis_client_runner - pkg_client_ui_cordis --> pkg_invariants pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_client_locale + pkg_session_log_export --> pkg_client_ui_commands + pkg_session_log_export --> pkg_client_ui_conversation + pkg_session_log_export --> pkg_client_ui_renderer + pkg_session_log_export --> pkg_client_ui_session + pkg_session_log_export --> pkg_commands + pkg_session_log_export --> pkg_invariants + pkg_client_ui_attachment --> pkg_attachment + pkg_client_ui_attachment --> pkg_client_ui_chat + pkg_client_ui_attachment --> pkg_client_ui_conversation + pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_connection + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_ui_chat + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_renderer + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_deliverables --> pkg_session + pkg_client_ui_deliverables --> pkg_system_prompt + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_api_session_controller + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_ui_chat + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_renderer + pkg_client_ui_goal --> pkg_client_ui_session + pkg_client_ui_goal --> pkg_commands + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_goal --> pkg_session + pkg_client_ui_goal --> pkg_typert_protocol + pkg_client_ui_message_feedback --> pkg_api_remotes + pkg_client_ui_message_feedback --> pkg_client_connection + pkg_client_ui_message_feedback --> pkg_client_locale + pkg_client_ui_message_feedback --> pkg_client_ui_chat + pkg_client_ui_message_feedback --> pkg_client_ui_conversation + pkg_client_ui_message_feedback --> pkg_client_ui_renderer + pkg_client_ui_message_feedback --> pkg_client_ui_session + pkg_client_ui_message_feedback --> pkg_invariants + pkg_client_ui_message_feedback --> pkg_message_feedback + pkg_client_ui_message_feedback --> pkg_session + pkg_client_ui_message_feedback --> pkg_typert_protocol + pkg_client_ui_model_selection --> pkg_api_remotes + pkg_client_ui_model_selection --> pkg_api_session_controller + pkg_client_ui_model_selection --> pkg_client_connection + pkg_client_ui_model_selection --> pkg_client_locale + pkg_client_ui_model_selection --> pkg_client_ui_commands + pkg_client_ui_model_selection --> pkg_client_ui_conversation + pkg_client_ui_model_selection --> pkg_client_ui_input_trigger + pkg_client_ui_model_selection --> pkg_client_ui_renderer + pkg_client_ui_model_selection --> pkg_client_ui_session + pkg_client_ui_model_selection --> pkg_invariants + pkg_client_ui_model_selection --> pkg_session + pkg_client_ui_model_selection --> pkg_typert_protocol + pkg_client_ui_permission_presets --> pkg_api_remotes + pkg_client_ui_permission_presets --> pkg_api_session_controller + pkg_client_ui_permission_presets --> pkg_client_connection + pkg_client_ui_permission_presets --> pkg_client_locale + pkg_client_ui_permission_presets --> pkg_client_ui_commands + pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger + pkg_client_ui_permission_presets --> pkg_client_ui_renderer + pkg_client_ui_permission_presets --> pkg_client_ui_session + pkg_client_ui_permission_presets --> pkg_client_ui_settings + pkg_client_ui_permission_presets --> pkg_invariants + pkg_client_ui_permission_presets --> pkg_permission_presets + pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_api_workspace_controller + pkg_client_ui_tool --> pkg_client_connection + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_ui_chat + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_renderer + pkg_client_ui_tool --> pkg_client_ui_session + pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_api_session_controller + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_ui_chat + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_renderer + pkg_client_ui_workflow_run --> pkg_client_ui_session + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_test_runtime --> pkg_api_session_controller + pkg_client_test_runtime --> pkg_api_workspace_controller + pkg_client_test_runtime --> pkg_attachment + pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_store + pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_conversation + pkg_client_test_runtime --> pkg_client_ui_renderer + pkg_client_test_runtime --> pkg_client_ui_session + pkg_client_test_runtime --> pkg_client_ui_settings + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_invariants + pkg_client_test_runtime --> pkg_session + pkg_client_ui_skill --> pkg_api_remotes + pkg_client_ui_skill --> pkg_api_session_controller + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_ui_input_trigger + pkg_client_ui_skill --> pkg_client_ui_renderer + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_skill --> pkg_session + pkg_client_ui_cordis --> pkg_api_remotes + pkg_client_ui_cordis --> pkg_client_connection + pkg_client_ui_cordis --> pkg_client_locale + pkg_client_ui_cordis --> pkg_client_ui_input_trigger + pkg_client_ui_cordis --> pkg_client_ui_renderer + pkg_client_ui_cordis --> pkg_client_ui_session + pkg_client_ui_cordis --> pkg_client_ui_sidebar + pkg_client_ui_cordis --> pkg_client_ui_tool + pkg_client_ui_cordis --> pkg_cordis_client_runner + pkg_client_ui_cordis --> pkg_invariants ``` | Package | Group | Depends on | @@ -1560,7 +1647,9 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1742,45 +1831,46 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-crypto`](../packages/util/crypto) | -| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | -| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index cf0f12f71f..86298ce1ed 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -131,10 +131,12 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] - pkg_client_runtime["client-runtime"] + pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] + pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -151,6 +153,7 @@ flowchart TD pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] + pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -364,7 +367,9 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants + pkg_client_ui_renderer --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_web --> pkg_invariants pkg_code_runtime --> pkg_invariants @@ -1238,6 +1243,7 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_invariants pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1254,6 +1260,7 @@ flowchart TD pkg_api_session_controller --> pkg_typert_registry pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1277,272 +1284,352 @@ flowchart TD pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_user_approval pkg_api_remotes --> pkg_user_questions - pkg_client_runtime --> pkg_agent - pkg_client_runtime --> pkg_api_gateway - pkg_client_runtime --> pkg_api_remotes - pkg_client_runtime --> pkg_api_session_controller - pkg_client_runtime --> pkg_api_workspace_controller - pkg_client_runtime --> pkg_attachment - pkg_client_runtime --> pkg_client_connection - pkg_client_runtime --> pkg_commands - pkg_client_runtime --> pkg_host_apiproxy - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_llm - pkg_client_runtime --> pkg_llm_retry - pkg_client_runtime --> pkg_session - pkg_client_runtime --> pkg_session_projection - pkg_client_runtime --> pkg_session_title - pkg_client_runtime --> pkg_tool_todo - pkg_client_runtime --> pkg_tools - pkg_client_runtime --> pkg_typert_protocol - pkg_client_runtime --> pkg_typert_registry - pkg_client_runtime --> pkg_util_crypto - pkg_client_ui_renderer --> pkg_client_runtime - pkg_client_ui_renderer --> pkg_invariants + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection - pkg_client_ui_settings --> pkg_client_runtime pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes pkg_client_locale --> pkg_client_connection - pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_renderer pkg_client_locale --> pkg_client_ui_settings pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_renderer - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_input_trigger --> pkg_client_locale - pkg_client_ui_input_trigger --> pkg_client_runtime - pkg_client_ui_input_trigger --> pkg_file_reference - pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale - pkg_client_ui_settings_models --> pkg_client_runtime + pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_settings_plugin_inventory --> pkg_api_remotes pkg_client_ui_settings_plugin_inventory --> pkg_client_locale - pkg_client_ui_settings_plugin_inventory --> pkg_client_runtime + pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_renderer pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale - pkg_client_ui_settings_plugins --> pkg_client_runtime + pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings pkg_client_ui_settings_plugins --> pkg_invariants pkg_client_ui_theme --> pkg_api_remotes pkg_client_ui_theme --> pkg_client_connection pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_renderer pkg_client_ui_theme --> pkg_client_ui_settings pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_theme --> pkg_settings - pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_renderer + pkg_client_ui_layout --> pkg_client_ui_session pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_cordis_client_runner --> pkg_api_remotes + pkg_cordis_client_runner --> pkg_client_connection + pkg_cordis_client_runner --> pkg_client_modules + pkg_cordis_client_runner --> pkg_client_ui_renderer + pkg_cordis_client_runner --> pkg_client_ui_theme + pkg_cordis_client_runner --> pkg_invariants + pkg_client_ui_conversation --> pkg_api_remotes + pkg_client_ui_conversation --> pkg_api_session_controller + pkg_client_ui_conversation --> pkg_api_workspace_controller + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_ui_layout + pkg_client_ui_conversation --> pkg_client_ui_renderer + pkg_client_ui_conversation --> pkg_client_ui_session + pkg_client_ui_conversation --> pkg_client_ui_settings + pkg_client_ui_conversation --> pkg_client_ui_workspace + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_goal + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_permission_presets + pkg_client_ui_conversation --> pkg_plan_mode + pkg_client_ui_conversation --> pkg_session + pkg_client_ui_conversation --> pkg_settings + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tool_todo + pkg_client_ui_conversation --> pkg_util_crypto + pkg_client_ui_conversation --> pkg_workspace + pkg_client_ui_sidebar --> pkg_api_workspace_controller + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_ui_layout + pkg_client_ui_sidebar --> pkg_client_ui_renderer + pkg_client_ui_sidebar --> pkg_client_ui_session + pkg_client_ui_sidebar --> pkg_client_ui_workspace + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_session_controller + pkg_client_ui_workspace --> pkg_api_workspace_controller + pkg_client_ui_workspace --> pkg_client_connection + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_ui_conversation + pkg_client_ui_workspace --> pkg_client_ui_renderer + pkg_client_ui_workspace --> pkg_client_ui_session + pkg_client_ui_workspace --> pkg_client_ui_sidebar + pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_workspace --> pkg_session + pkg_client_ui_agent_preset --> pkg_api_remotes + pkg_client_ui_agent_preset --> pkg_api_session_controller + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_renderer + pkg_client_ui_agent_preset --> pkg_client_ui_session + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_workspace + pkg_client_ui_agent_preset --> pkg_invariants + pkg_client_ui_agent_preset --> pkg_session + pkg_client_ui_approval --> pkg_api_remotes + pkg_client_ui_approval --> pkg_api_session_controller + pkg_client_ui_approval --> pkg_client_locale + pkg_client_ui_approval --> pkg_client_ui_conversation + pkg_client_ui_approval --> pkg_client_ui_renderer + pkg_client_ui_approval --> pkg_client_ui_session + pkg_client_ui_approval --> pkg_invariants + pkg_client_ui_approval --> pkg_llm + pkg_client_ui_approval --> pkg_session + pkg_client_ui_approval --> pkg_typert_protocol + pkg_client_ui_brand_official --> pkg_client_ui_conversation + pkg_client_ui_brand_official --> pkg_client_ui_renderer + pkg_client_ui_brand_official --> pkg_client_ui_sidebar + pkg_client_ui_brand_official --> pkg_invariants + pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_client_locale + pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_browse --> pkg_invariants + pkg_client_ui_directory_picker_native --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_native --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_api_session_controller + pkg_client_ui_input_trigger --> pkg_client_locale + pkg_client_ui_input_trigger --> pkg_client_ui_conversation + pkg_client_ui_input_trigger --> pkg_client_ui_renderer + pkg_client_ui_input_trigger --> pkg_client_ui_session + pkg_client_ui_input_trigger --> pkg_file_reference + pkg_client_ui_input_trigger --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_session + pkg_client_ui_jobs --> pkg_api_session_controller + pkg_client_ui_jobs --> pkg_client_locale + pkg_client_ui_jobs --> pkg_client_ui_conversation + pkg_client_ui_jobs --> pkg_client_ui_renderer + pkg_client_ui_jobs --> pkg_client_ui_session + pkg_client_ui_jobs --> pkg_invariants + pkg_client_ui_plan --> pkg_api_remotes + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_renderer + pkg_client_ui_plan --> pkg_client_ui_session + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_plan --> pkg_session + pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_ui_renderer + pkg_client_ui_settings_general --> pkg_client_ui_session + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_sidebar + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_ui_conversation + pkg_client_ui_trajectory --> pkg_client_ui_renderer + pkg_client_ui_trajectory --> pkg_client_ui_session + pkg_client_ui_trajectory --> pkg_compaction + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_session + pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_user_questions --> pkg_api_remotes + pkg_client_ui_user_questions --> pkg_api_session_controller + pkg_client_ui_user_questions --> pkg_client_locale + pkg_client_ui_user_questions --> pkg_client_ui_conversation + pkg_client_ui_user_questions --> pkg_client_ui_renderer + pkg_client_ui_user_questions --> pkg_client_ui_session + pkg_client_ui_user_questions --> pkg_invariants + pkg_client_ui_user_questions --> pkg_session + pkg_client_ui_user_questions --> pkg_typert_protocol + pkg_client_ui_user_questions --> pkg_user_questions + pkg_client_ui_chat --> pkg_agent + pkg_client_ui_chat --> pkg_api_remotes + pkg_client_ui_chat --> pkg_api_session_controller + pkg_client_ui_chat --> pkg_api_workspace_controller + pkg_client_ui_chat --> pkg_attachment + pkg_client_ui_chat --> pkg_client_locale + pkg_client_ui_chat --> pkg_client_ui_approval + pkg_client_ui_chat --> pkg_client_ui_conversation + pkg_client_ui_chat --> pkg_client_ui_layout + pkg_client_ui_chat --> pkg_client_ui_renderer + pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_workspace + pkg_client_ui_chat --> pkg_commands + pkg_client_ui_chat --> pkg_compaction + pkg_client_ui_chat --> pkg_invariants + pkg_client_ui_chat --> pkg_llm + pkg_client_ui_chat --> pkg_llm_retry + pkg_client_ui_chat --> pkg_session + pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_token_meter + pkg_client_ui_chat --> pkg_tools + pkg_client_ui_commands --> pkg_api_remotes + pkg_client_ui_commands --> pkg_api_session_controller + pkg_client_ui_commands --> pkg_client_locale + pkg_client_ui_commands --> pkg_client_ui_conversation + pkg_client_ui_commands --> pkg_client_ui_input_trigger + pkg_client_ui_commands --> pkg_client_ui_renderer + pkg_client_ui_commands --> pkg_client_ui_session + pkg_client_ui_commands --> pkg_commands + pkg_client_ui_commands --> pkg_invariants + pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes pkg_client_ui_reference --> pkg_client_locale - pkg_client_ui_reference --> pkg_client_runtime pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol - pkg_cordis_client_runner --> pkg_api_remotes - pkg_cordis_client_runner --> pkg_client_connection - pkg_cordis_client_runner --> pkg_client_modules - pkg_cordis_client_runner --> pkg_client_runtime - pkg_cordis_client_runner --> pkg_client_ui_theme - pkg_cordis_client_runner --> pkg_invariants - pkg_client_ui_conversation --> pkg_agent - pkg_client_ui_conversation --> pkg_api_remotes - pkg_client_ui_conversation --> pkg_attachment - pkg_client_ui_conversation --> pkg_brand - pkg_client_ui_conversation --> pkg_client_connection - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_input_trigger - pkg_client_ui_conversation --> pkg_client_ui_layout - pkg_client_ui_conversation --> pkg_client_ui_settings - pkg_client_ui_conversation --> pkg_commands - pkg_client_ui_conversation --> pkg_compaction - pkg_client_ui_conversation --> pkg_goal - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_llm_retry - pkg_client_ui_conversation --> pkg_permission_presets - pkg_client_ui_conversation --> pkg_plan_mode - pkg_client_ui_conversation --> pkg_session_stats - pkg_client_ui_conversation --> pkg_settings - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_conversation --> pkg_tool_todo - pkg_client_ui_conversation --> pkg_tools - pkg_client_ui_conversation --> pkg_util_crypto - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_layout - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_agent_preset --> pkg_api_remotes - pkg_client_ui_agent_preset --> pkg_client_connection - pkg_client_ui_agent_preset --> pkg_client_locale - pkg_client_ui_agent_preset --> pkg_client_runtime - pkg_client_ui_agent_preset --> pkg_client_ui_conversation - pkg_client_ui_agent_preset --> pkg_client_ui_settings - pkg_client_ui_agent_preset --> pkg_invariants - pkg_client_ui_attachment --> pkg_attachment - pkg_client_ui_attachment --> pkg_client_runtime - pkg_client_ui_attachment --> pkg_client_ui_conversation - pkg_client_ui_attachment --> pkg_invariants - pkg_client_ui_brand_official --> pkg_client_runtime - pkg_client_ui_brand_official --> pkg_client_ui_conversation - pkg_client_ui_brand_official --> pkg_client_ui_sidebar - pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_commands --> pkg_api_remotes - pkg_client_ui_commands --> pkg_client_locale - pkg_client_ui_commands --> pkg_client_runtime - pkg_client_ui_commands --> pkg_client_ui_conversation - pkg_client_ui_commands --> pkg_client_ui_input_trigger - pkg_client_ui_commands --> pkg_commands - pkg_client_ui_commands --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_connection - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_deliverables --> pkg_system_prompt - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_commands - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_goal --> pkg_session - pkg_client_ui_goal --> pkg_typert_protocol - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_runtime - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_api_remotes - pkg_client_ui_message_feedback --> pkg_client_connection - pkg_client_ui_message_feedback --> pkg_client_locale - pkg_client_ui_message_feedback --> pkg_client_runtime - pkg_client_ui_message_feedback --> pkg_client_ui_conversation - pkg_client_ui_message_feedback --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_message_feedback - pkg_client_ui_message_feedback --> pkg_typert_protocol - pkg_client_ui_plan --> pkg_api_remotes - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_settings_general --> pkg_api_remotes - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_sidebar - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_subagent --> pkg_api_session_controller + pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation pkg_client_ui_subagent --> pkg_client_ui_input_trigger + pkg_client_ui_subagent --> pkg_client_ui_renderer + pkg_client_ui_subagent --> pkg_client_ui_session pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_session pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_client_ui_tool --> pkg_api_remotes - pkg_client_ui_tool --> pkg_client_connection - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_invariants - pkg_client_ui_trajectory --> pkg_agent - pkg_client_ui_trajectory --> pkg_client_locale - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_conversation - pkg_client_ui_trajectory --> pkg_compaction - pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_trajectory --> pkg_tools - pkg_client_ui_user_questions --> pkg_api_remotes - pkg_client_ui_user_questions --> pkg_client_locale - pkg_client_ui_user_questions --> pkg_client_runtime - pkg_client_ui_user_questions --> pkg_client_ui_conversation - pkg_client_ui_user_questions --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_client_locale - pkg_client_ui_workflow_run --> pkg_client_runtime - pkg_client_ui_workflow_run --> pkg_client_ui_conversation - pkg_client_ui_workflow_run --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_session - pkg_client_ui_workflow_run --> pkg_tool_workflow - pkg_client_ui_workflow_run --> pkg_workflow - pkg_client_ui_workspace --> pkg_client_connection - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_conversation - pkg_client_ui_workspace --> pkg_client_ui_sidebar - pkg_client_ui_workspace --> pkg_invariants - pkg_session_log_export --> pkg_client_locale - pkg_session_log_export --> pkg_client_runtime - pkg_session_log_export --> pkg_client_ui_commands - pkg_session_log_export --> pkg_client_ui_conversation - pkg_session_log_export --> pkg_commands - pkg_session_log_export --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_locale - pkg_client_ui_directory_picker_browse --> pkg_client_runtime - pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_browse --> pkg_invariants - pkg_client_ui_directory_picker_native --> pkg_client_runtime - pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_native --> pkg_invariants - pkg_client_ui_model_selection --> pkg_api_remotes - pkg_client_ui_model_selection --> pkg_api_session_controller - pkg_client_ui_model_selection --> pkg_client_connection - pkg_client_ui_model_selection --> pkg_client_locale - pkg_client_ui_model_selection --> pkg_client_runtime - pkg_client_ui_model_selection --> pkg_client_ui_commands - pkg_client_ui_model_selection --> pkg_client_ui_conversation - pkg_client_ui_model_selection --> pkg_client_ui_input_trigger - pkg_client_ui_model_selection --> pkg_invariants - pkg_client_ui_model_selection --> pkg_typert_protocol - pkg_client_ui_permission_presets --> pkg_api_remotes - pkg_client_ui_permission_presets --> pkg_client_connection - pkg_client_ui_permission_presets --> pkg_client_locale - pkg_client_ui_permission_presets --> pkg_client_runtime - pkg_client_ui_permission_presets --> pkg_client_ui_commands - pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger - pkg_client_ui_permission_presets --> pkg_client_ui_settings - pkg_client_ui_permission_presets --> pkg_invariants - pkg_client_ui_permission_presets --> pkg_permission_presets - pkg_client_ui_skill --> pkg_api_remotes - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_input_trigger - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_cordis --> pkg_api_remotes - pkg_client_ui_cordis --> pkg_client_connection - pkg_client_ui_cordis --> pkg_client_locale - pkg_client_ui_cordis --> pkg_client_runtime - pkg_client_ui_cordis --> pkg_client_ui_input_trigger - pkg_client_ui_cordis --> pkg_client_ui_sidebar - pkg_client_ui_cordis --> pkg_client_ui_tool - pkg_client_ui_cordis --> pkg_cordis_client_runner - pkg_client_ui_cordis --> pkg_invariants pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_client_locale + pkg_session_log_export --> pkg_client_ui_commands + pkg_session_log_export --> pkg_client_ui_conversation + pkg_session_log_export --> pkg_client_ui_renderer + pkg_session_log_export --> pkg_client_ui_session + pkg_session_log_export --> pkg_commands + pkg_session_log_export --> pkg_invariants + pkg_client_ui_attachment --> pkg_attachment + pkg_client_ui_attachment --> pkg_client_ui_chat + pkg_client_ui_attachment --> pkg_client_ui_conversation + pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_connection + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_ui_chat + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_renderer + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_deliverables --> pkg_session + pkg_client_ui_deliverables --> pkg_system_prompt + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_api_session_controller + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_ui_chat + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_renderer + pkg_client_ui_goal --> pkg_client_ui_session + pkg_client_ui_goal --> pkg_commands + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_goal --> pkg_session + pkg_client_ui_goal --> pkg_typert_protocol + pkg_client_ui_message_feedback --> pkg_api_remotes + pkg_client_ui_message_feedback --> pkg_client_connection + pkg_client_ui_message_feedback --> pkg_client_locale + pkg_client_ui_message_feedback --> pkg_client_ui_chat + pkg_client_ui_message_feedback --> pkg_client_ui_conversation + pkg_client_ui_message_feedback --> pkg_client_ui_renderer + pkg_client_ui_message_feedback --> pkg_client_ui_session + pkg_client_ui_message_feedback --> pkg_invariants + pkg_client_ui_message_feedback --> pkg_message_feedback + pkg_client_ui_message_feedback --> pkg_session + pkg_client_ui_message_feedback --> pkg_typert_protocol + pkg_client_ui_model_selection --> pkg_api_remotes + pkg_client_ui_model_selection --> pkg_api_session_controller + pkg_client_ui_model_selection --> pkg_client_connection + pkg_client_ui_model_selection --> pkg_client_locale + pkg_client_ui_model_selection --> pkg_client_ui_commands + pkg_client_ui_model_selection --> pkg_client_ui_conversation + pkg_client_ui_model_selection --> pkg_client_ui_input_trigger + pkg_client_ui_model_selection --> pkg_client_ui_renderer + pkg_client_ui_model_selection --> pkg_client_ui_session + pkg_client_ui_model_selection --> pkg_invariants + pkg_client_ui_model_selection --> pkg_session + pkg_client_ui_model_selection --> pkg_typert_protocol + pkg_client_ui_permission_presets --> pkg_api_remotes + pkg_client_ui_permission_presets --> pkg_api_session_controller + pkg_client_ui_permission_presets --> pkg_client_connection + pkg_client_ui_permission_presets --> pkg_client_locale + pkg_client_ui_permission_presets --> pkg_client_ui_commands + pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger + pkg_client_ui_permission_presets --> pkg_client_ui_renderer + pkg_client_ui_permission_presets --> pkg_client_ui_session + pkg_client_ui_permission_presets --> pkg_client_ui_settings + pkg_client_ui_permission_presets --> pkg_invariants + pkg_client_ui_permission_presets --> pkg_permission_presets + pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_api_workspace_controller + pkg_client_ui_tool --> pkg_client_connection + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_ui_chat + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_renderer + pkg_client_ui_tool --> pkg_client_ui_session + pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_api_session_controller + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_ui_chat + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_renderer + pkg_client_ui_workflow_run --> pkg_client_ui_session + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_test_runtime --> pkg_api_session_controller + pkg_client_test_runtime --> pkg_api_workspace_controller + pkg_client_test_runtime --> pkg_attachment + pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_store + pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_conversation + pkg_client_test_runtime --> pkg_client_ui_renderer + pkg_client_test_runtime --> pkg_client_ui_session + pkg_client_test_runtime --> pkg_client_ui_settings + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_invariants + pkg_client_test_runtime --> pkg_session + pkg_client_ui_skill --> pkg_api_remotes + pkg_client_ui_skill --> pkg_api_session_controller + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_ui_input_trigger + pkg_client_ui_skill --> pkg_client_ui_renderer + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_skill --> pkg_session + pkg_client_ui_cordis --> pkg_api_remotes + pkg_client_ui_cordis --> pkg_client_connection + pkg_client_ui_cordis --> pkg_client_locale + pkg_client_ui_cordis --> pkg_client_ui_input_trigger + pkg_client_ui_cordis --> pkg_client_ui_renderer + pkg_client_ui_cordis --> pkg_client_ui_session + pkg_client_ui_cordis --> pkg_client_ui_sidebar + pkg_client_ui_cordis --> pkg_client_ui_tool + pkg_client_ui_cordis --> pkg_cordis_client_runner + pkg_client_ui_cordis --> pkg_invariants ``` | Package | Group | Depends on | @@ -1562,7 +1649,9 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1744,45 +1833,46 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-crypto`](../packages/util/crypto) | -| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | -| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/user-questions.i18n.yaml b/docs/subsystems/user-questions.i18n.yaml index b9ee6605e6..1138e03de1 100644 --- a/docs/subsystems/user-questions.i18n.yaml +++ b/docs/subsystems/user-questions.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 docs/subsystems/user-questions.md -user-questions.md: 93b1bc52580da16fe8ad0de4bc16ec309aaacc1f -user-questions.zh.md: 50098f6e0f6b1b38a1035566b04e24e0b7a80ff7 +user-questions.md: fbbfb1435586c7191e47a6eaa5b1783c9f172d48 +user-questions.zh.md: 054ca06cd3c8a85cb35d99658c325848ea780cb5 diff --git a/docs/subsystems/user-questions.md b/docs/subsystems/user-questions.md index 93b1bc5258..fbbfb14355 100644 --- a/docs/subsystems/user-questions.md +++ b/docs/subsystems/user-questions.md @@ -2,7 +2,7 @@ English | [中文](user-questions.zh.md) -The user-questions seam of [dsh-user-questions](../../packages/interaction/user-questions). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserQuestionProvider`; the host runtime relays requests to its connected client. +The user-questions seam of [dsh-user-questions](../../packages/interaction/user-questions). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. Agent-scoped waterfall listeners compose the available UI surfaces, including listeners relayed to a connected client. Source: [`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts) @@ -74,14 +74,7 @@ interface AskUserQuestionItem { ```ts type-equiv /** Request for a human answer. */ -interface AskUserQuestionRequest { - /** Questions to display. */ - questions: AskUserQuestionItem[] - /** Exact live calling agent, when the request came from an agent tool call. */ - agent?: Agent - /** Abort signal for the owning tool/step. */ - signal?: AbortSignal -} +interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {} ``` ## Answer @@ -108,17 +101,6 @@ interface AskUserQuestionAnswer { } ``` -## Provider - -Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI. - -```ts type-equiv -/** UI-side provider for user questions. */ -interface UserQuestionProvider { - ask(request: AskUserQuestionRequest): Promise -} -``` - ## Errors `UserQuestionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or UI-side cancellation. @@ -145,19 +127,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.userQuestions` — `UserQuestionService` -`ctx.userQuestions`: one active UI provider plus an `ask()` API. +`ctx.userQuestions`: validation plus the scoped answerer waterfall. ```ts cordis-catalog /** - * Register the UI provider. Only one provider may be active in a context. - * - * @param provider UI-side implementation that collects answers. - * @returns Disposer that unregisters this provider. - */ -registerProvider(provider: UserQuestionProvider): () => void - -/** - * Ask the active UI provider and wait for the user's answer. + * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the * exact live runtime root. Runtime ownership, not durable session lineage, diff --git a/docs/subsystems/user-questions.zh.md b/docs/subsystems/user-questions.zh.md index 50098f6e0f..054ca06cd3 100644 --- a/docs/subsystems/user-questions.zh.md +++ b/docs/subsystems/user-questions.zh.md @@ -2,7 +2,7 @@ [English](user-questions.md) | 中文 -[dsh-user-questions](../../packages/interaction/user-questions) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。UI 界面提供活跃的 `UserQuestionProvider`;host 运行时把请求转发给其连接的客户端。 +[dsh-user-questions](../../packages/interaction/user-questions) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。Agent-scoped waterfall listener 组合可用的 UI 界面,其中包括转发到已连接 client 的 listener。 源码:[`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts) @@ -74,14 +74,7 @@ interface AskUserQuestionItem { ```ts type-equiv /** Request for a human answer. */ -interface AskUserQuestionRequest { - /** Questions to display. */ - questions: AskUserQuestionItem[] - /** Exact live calling agent, when the request came from an agent tool call. */ - agent?: Agent - /** Abort signal for the owning tool/step. */ - signal?: AbortSignal -} +interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {} ``` ## 回答 @@ -108,17 +101,6 @@ interface AskUserQuestionAnswer { } ``` -## 提供方 - -同一上下文中只能有一个活跃的提供方。提供方注册绑定到 effect,因此 HMR(热模块替换)或 dispose(资源释放)会移除当前活跃的 UI。 - -```ts type-equiv -/** UI-side provider for user questions. */ -interface UserQuestionProvider { - ask(request: AskUserQuestionRequest): Promise -} -``` - ## 错误 `UserQuestionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会保留 `{ name, code }`,用于面向模型的工具失败,如 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`ASK_ABORTED` 或 UI 侧取消。 @@ -145,19 +127,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.userQuestions` — `UserQuestionService` -`ctx.userQuestions`: one active UI provider plus an `ask()` API. +`ctx.userQuestions`: validation plus the scoped answerer waterfall. ```ts cordis-catalog /** - * Register the UI provider. Only one provider may be active in a context. - * - * @param provider UI-side implementation that collects answers. - * @returns Disposer that unregisters this provider. - */ -registerProvider(provider: UserQuestionProvider): () => void - -/** - * Ask the active UI provider and wait for the user's answer. + * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the * exact live runtime root. Runtime ownership, not durable session lineage, diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index e0cc0dd70d..cfeead8e18 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.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 packages/api/gateway/README.md -README.md: fe4834b4ca36590bc289be9b6863ffb082e8ee71 -README.zh.md: dff5a58599f0d3bf97996ceb36b134bf62da8ca0 +README.md: 2546b0c4e54ea106c9203ce419027fe8253c7ca5 +README.zh.md: 9281519cda422137c6fd08ff6680ba0d57902913 diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 50a7c83058..fd100820c1 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md README.md: 0c4f0fcab4a741f457f1ffbdd9a4ba7688b9d32c -README.zh.md: b381751cec5285c34bb310b330fc55d34033cbf2 +README.zh.md: f83e31e63b57f09b16403df911154c9d93b07958 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 9baa35ebd0..93e3cba07b 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -51,7 +51,7 @@ Non-negotiables across the layers: - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. - **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). -- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`. +- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule). ## Dependency declaration diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index d80ae58f90..db771381dd 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.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 packages/client/README.md -README.md: b18aad486cd7d3fafe8261fee61fa9e26a7feaa6 -README.zh.md: 58c9aeb5c91f13d9306ba8de16de5af760fd43a2 +README.md: eaf01b282ead3c4638435e3d02d6a803ad1faa15 +README.zh.md: 2ff4b1529f07e0f31b3d06a9577d3c480dd34916 diff --git a/packages/client/README.md b/packages/client/README.md index b18aad486c..eaf01b282e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -10,11 +10,12 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-renderer/`](ui-renderer/README.md) | Binds slot data to React and mounts the assembled application after client boot settles. | | [`modules/`](modules/README.md) | Loads browser-side client modules. | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery. | -| [`runtime/`](runtime/README.md) | Provides shared client services for sessions, workspaces, and UI composition. | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development. | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries. | +| [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives. | | [`test-runtime/`](../test-support/client-runtime/README.md) | Provides shared repository test support for client feature packages. | | [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. | +| [`ui-session/`](ui-session/README.md) | Adapts Session Controller state into standard Slot sources and hooks. | | [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. | | [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. | | [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation. | @@ -23,6 +24,8 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks. | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. | +| [`ui-chat/`](ui-chat/README.md) | Projects and renders the Chat conversation target. | +| [`ui-approval/`](ui-approval/README.md) | Presents approval requests and returns user decisions. | | [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. | | [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. | | [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 58c9aeb5c9..2ff4b1529f 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -10,11 +10,12 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-renderer/`](ui-renderer/README.zh.md) | 将 slot 数据绑定到 React,并在客户端启动稳定后挂载组装完成的应用。 | | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块。 | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 | -| [`runtime/`](runtime/README.zh.md) | 为会话、工作区和 UI 组合提供共享客户端服务。 | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件。 | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典。 | +| [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与 snapshot-store 基础设施。 | | [`test-runtime/`](../test-support/client-runtime/README.zh.md) | 为客户端功能包提供共享的仓库测试支持。 | | [`ui-slots/`](ui-slots/README.zh.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 | +| [`ui-session/`](ui-session/README.zh.md) | 把 Session Controller 状态适配为标准 Slot source 与 hook。 | | [`ui-theme/`](ui-theme/README.zh.md) | 应用所选颜色主题。 | | [`ui-primitives/`](ui-primitives/README.zh.md) | 提供共享 React 控件、图标和内容渲染器。 | | [`ui-attachment/`](ui-attachment/README.zh.md) | 注册输入框与消息图片的附件呈现。 | @@ -23,6 +24,8 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-brand-official/`](ui-brand-official/README.zh.md) | 使用官方名称和标记填充通用浏览器品牌 slot。 | | [`ui-workspace/`](ui-workspace/README.zh.md) | 提供工作区选择与创建界面。 | | [`ui-conversation/`](ui-conversation/README.zh.md) | 展示当前对话及其输入界面。 | +| [`ui-chat/`](ui-chat/README.zh.md) | 投影并渲染 Chat conversation target。 | +| [`ui-approval/`](ui-approval/README.zh.md) | 展示审批请求并返回用户决定。 | | [`ui-tool/`](ui-tool/README.zh.md) | 编排工具调用树和按工具键控的视图。 | | [`ui-workflow-run/`](ui-workflow-run/README.zh.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 | | [`ui-goal/`](ui-goal/README.zh.md) | 展示和管理当前目标。 | diff --git a/packages/client/store/README.i18n.yaml b/packages/client/store/README.i18n.yaml new file mode 100644 index 0000000000..a67f7a84ec --- /dev/null +++ b/packages/client/store/README.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 packages/client/store/README.md +README.md: 51299c9730157eaf1cd7226d64a54e08b497b76e +README.zh.md: 1f2883db9a0b591a12438eba96f2fb36d96fbda0 diff --git a/packages/client/store/README.md b/packages/client/store/README.md new file mode 100644 index 0000000000..51299c9730 --- /dev/null +++ b/packages/client/store/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-store + +English | [中文](README.zh.md) + +React-free observable and snapshot-store primitives shared by Client controllers and renderer adapters. The package owns synchronous and animation-frame publication, Immer-backed updates, shallow equality, and optional browser persistence; React hook construction remains in `@deepseek-ai/dsh-client-ui-renderer`. + +## Model Experience + +None, as this package provides browser-side state primitives and registers nothing model-facing. + +#### KV Cache effect + +None; the stores neither assemble nor send model requests. + +## Known Limitations and Deferred Work + +- **Persistence is browser-local** — persisted stores use JSON in `localStorage`; non-browser runtimes disable persistence, and the package provides no cross-device synchronization. diff --git a/packages/client/store/README.zh.md b/packages/client/store/README.zh.md new file mode 100644 index 0000000000..1f2883db9a --- /dev/null +++ b/packages/client/store/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-store + +[English](README.md) | 中文 + +供 Client controller 与 renderer adapter 共用的不依赖 React 的 observable 和 snapshot-store 基础设施。本包负责同步与 animation-frame 发布、基于 Immer 的更新、浅比较和可选的浏览器持久化;React hook 的构造仍属于 `@deepseek-ai/dsh-client-ui-renderer`。 + +## 模型体验 + +无,因为本包提供浏览器侧状态基础设施,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;这些 store 既不组装也不发送模型请求。 + +## 已知限制与暂缓事项 + +- **持久化仅限浏览器本地**——持久化 store 使用 `localStorage` 中的 JSON;非浏览器运行时会禁用持久化,本包也不提供跨设备同步。 diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml new file mode 100644 index 0000000000..8158657bd3 --- /dev/null +++ b/packages/client/ui-approval/README.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 packages/client/ui-approval/README.md +README.md: efc3a81ad88d94b2835b32a7663f72c2e95c1614 +README.zh.md: 8eaa561b9cbdffd0998b568109497c0e9587f043 diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md new file mode 100644 index 0000000000..efc3a81ad8 --- /dev/null +++ b/packages/client/ui-approval/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-approval + +English | [中文](README.zh.md) + +Browser approval presentation over the Agent-scoped Remote Event waterfall. The plugin publishes each pending request through `ctx.uiSession`, takes over the Conversation composer, optionally renders correlated Tool detail, and returns the user's decision to the waiting Host request. + +## Model Experience + +None, as this package presents approval requests in the browser and registers nothing model-facing. + +#### KV Cache effect + +None; approval request and response rendering does not alter a model request. + +## Known Limitations and Deferred Work + +- **The panel exposes transient decisions only** — it supports allow-once and reject; persistent permission policy remains owned by Host-side approval packages. diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md new file mode 100644 index 0000000000..8eaa561b9c --- /dev/null +++ b/packages/client/ui-approval/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-approval + +[English](README.md) | 中文 + +基于 Agent-scoped Remote Event waterfall 的浏览器审批界面。插件通过 `ctx.uiSession` 发布每个待处理请求、接管 Conversation composer、按需渲染关联的 Tool 详情,并将用户决定返回给等待中的 Host 请求。 + +## 模型体验 + +无,因为本包只在浏览器中呈现审批请求,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;审批请求和响应的呈现不会改变模型请求。 + +## 已知限制与暂缓事项 + +- **面板只提供临时决定**——它支持仅本次允许和拒绝;持久权限策略仍由 Host 侧审批 package 拥有。 diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index 204af86f03..9e326f18f0 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -56,6 +56,7 @@ function setupPlugin(): PluginBench { const registerPendingInteraction = vi.fn((_precedence: (value: PendingApproval) => number) => ( value: PendingApproval, ) => { + _precedence(value) pending = [...pending, value] return () => { pending = pending.filter(candidate => candidate !== value) } }) @@ -257,6 +258,22 @@ describe('approval Remote Event consumer', () => { await scope.fiber.dispose() }) + it('publishes a scoped request without optional request metadata', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const result = bench.listener.call(scope.ctx, { toolName: 'read' }, () => Promise.resolve('unavailable')) + const pending = bench.pending.getSnapshot()[0]! + + await pending.answer('rejected') + + await expect(result).resolves.toBe('rejected') + expect(pending).toMatchObject({ toolName: 'read' }) + expect(pending.callId).toBeUndefined() + expect(pending.reason).toBeUndefined() + await scope.fiber.dispose() + }) + it('removes stable registrations with the plugin lifetime', async () => { const bench = setupPlugin() await bench.ctx.fiber.dispose() diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml new file mode 100644 index 0000000000..4abebdd3f3 --- /dev/null +++ b/packages/client/ui-chat/README.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 packages/client/ui-chat/README.md +README.md: 56bb20ab7b4d9b5c0c95142b311a07ad9b8a1fd3 +README.zh.md: 40ee1ee710e2f86e802ffaac4dc0bb10852f128f diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md new file mode 100644 index 0000000000..56bb20ab7b --- /dev/null +++ b/packages/client/ui-chat/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-chat + +English | [中文](README.zh.md) + +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, historical images, and scroll restoration. + +## Model Experience + +None, as this package renders logged conversation state in the browser and registers nothing model-facing. + +#### KV Cache effect + +None; Chat presentation does not assemble or mutate provider requests. + +## Known Limitations and Deferred Work + +- **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md new file mode 100644 index 0000000000..40ee1ee710 --- /dev/null +++ b/packages/client/ui-chat/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-chat + +[English](README.md) | 中文 + +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化、历史图片与滚动位置恢复。 + +## 模型体验 + +无,因为本包在浏览器中渲染已记录的对话状态,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;Chat 呈现不会组装或修改提供方请求。 + +## 已知限制与暂缓事项 + +- **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 081d70e9cd..ffb66bc995 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: b70bb96cc877279cc29662773eddf6fd7d6453ca -README.zh.md: 604beeb336054376971277446e1690137132c29e +README.md: 6f47cd87af46cc270d3160482ad047b249aa5053 +README.zh.md: 38e3c626073e9e90a16eebdb91af1e89d4da7c22 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b70bb96cc8..6f47cd87af 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -24,10 +24,15 @@ The resident composer survives no-Session and Session transitions. The no-Sessio `conversation.composer` is a generic chain. Its complete owner currency is: -```ts -export interface ComposerChainProps { +```ts type-equiv +/** Owner values used to elect a composer takeover. */ +interface ComposerChainProps { + /** Current Session identity used by temporary business-owned entries. */ sessionId: SessionId | undefined + /** Current Session lifecycle state, absent without a selected Session. */ session: SessionSnapshot | undefined + /** Effective business-owned interaction awaiting the user in this Session. */ + pendingInteraction: SessionPendingInteraction | undefined } ``` @@ -62,6 +67,14 @@ try { The selector must be a pure function of the owner currency. Its non-null return is delivered to the component as `matched`; `PropsRuntime<'conversation.composer'>` supplies the standard Session and global props. Chain order remains ascending `priority`, then registration order, and the first non-null selector wins. The shell keeps the default composer mounted beneath a takeover. Request state, listeners, response encoding, and any request-specific child slots belong to the business package; they are not carried by `SessionSnapshot` or declared by this core package. -## Model experience +## Model Experience -None. The package renders browser state and sends user-admitted inputs through Session Controller APIs; it does not construct model requests. +None, as this package renders browser state and sends user-admitted inputs through Session Controller APIs without constructing model requests. + +#### KV Cache effect + +None; Conversation assembly and browser input state do not alter provider-side prompt caching. + +## Known Limitations and Deferred Work + +- **Only registered targets can render** — the shell deliberately has no implicit fallback target beyond the registered `chat` preference. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 604beeb336..38e3c62607 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -24,10 +24,15 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 `conversation.composer` 是通用 chain,其完整 owner currency 为: -```ts -export interface ComposerChainProps { +```ts type-equiv +/** Owner values used to elect a composer takeover. */ +interface ComposerChainProps { + /** Current Session identity used by temporary business-owned entries. */ sessionId: SessionId | undefined + /** Current Session lifecycle state, absent without a selected Session. */ session: SessionSnapshot | undefined + /** Effective business-owned interaction awaiting the user in this Session. */ + pendingInteraction: SessionPendingInteraction | undefined } ``` @@ -64,4 +69,12 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match ## 模型体验 -无。本包渲染浏览器状态,并通过 Session Controller API 发送用户确认提交的输入;它不构造模型请求。 +无,因为本包渲染浏览器状态,并通过 Session Controller API 发送用户确认提交的输入,而不构造模型请求。 + +#### KV Cache 影响 + +无;Conversation 组装和浏览器输入状态不会改变提供方侧的 prompt cache。 + +## 已知限制与暂缓事项 + +- **只有已注册 target 可以渲染**——除已注册的 `chat` 偏好外,shell 刻意不提供隐式 fallback target。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a570478a45..0edc19c42f 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 2767bc42f9..23ebb49d68 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.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 packages/client/ui-jobs/README.md -README.md: e8c1852aca0b7fb375b3c58748a9f37385e8ebee -README.zh.md: b9f980283a7cbddce3c8a4de07079e5fc3b61212 +README.md: 0523aa10ebfbaa6cde0bef391017bc0307d8bdd5 +README.zh.md: bb713f446855a9e8b23c5543827b9897f1177a48 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index e8c1852aca..0523aa10eb 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web background-job feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.jobs` records this session can see. The data arrives entirely through the `jobsBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/jobs` frames, so this package issues no RPC and holds no state beyond popover visibility. +Web background-job feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.jobs` records this session can see. The data arrives entirely through the `jobsBySession` list mirror that the [Session Controller](../../api/session-controller/README.md) folds from `session/jobs` frames, so this package issues no RPC and holds no state beyond popover visibility. The trigger renders only when the session has at least one job, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished jobs a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index b9f980283a..bb713f4468 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.jobs` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.zh.md) 从 `session/jobs` 帧折叠出的 `jobsBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 +Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.jobs` 记录。数据完全来自 [Session Controller](../../api/session-controller/README.zh.md) 从 `session/jobs` 帧折叠出的 `jobsBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。 diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml new file mode 100644 index 0000000000..f5a325ce8c --- /dev/null +++ b/packages/client/ui-session/README.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 packages/client/ui-session/README.md +README.md: d18dec6232ff43112af761e27104cc50db84b169 +README.zh.md: 4c72c767a3a2cddef7dc9793b88425b3d20a4d90 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md new file mode 100644 index 0000000000..d18dec6232 --- /dev/null +++ b/packages/client/ui-session/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-session + +English | [中文](README.zh.md) + +React and Slot adapter for Session Controller state. It contributes Session list and pending-interaction hooks at root scope, materializes per-Session hooks and props, and owns the standard `SessionProvider` rendering behavior without taking ownership of Session transport or lifecycle state. + +## Model Experience + +None, as this package adapts browser-side Session state and registers nothing model-facing. + +#### KV Cache effect + +None; Session selectors and Slot scopes do not assemble model requests. + +## Known Limitations and Deferred Work + +- **Pending interactions are process-local projections** — the owning Remote waterfall must replay an outstanding request after a browser reconnect. diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md new file mode 100644 index 0000000000..4c72c767a3 --- /dev/null +++ b/packages/client/ui-session/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-session + +[English](README.md) | 中文 + +面向 Session Controller 状态的 React 与 Slot adapter。本包在 root scope 提供 Session list 和 pending-interaction hook,物化逐 Session hook 与 prop,并拥有标准 `SessionProvider` 渲染行为,但不接管 Session transport 或 lifecycle 状态。 + +## 模型体验 + +无,因为本包适配浏览器侧 Session 状态,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;Session selector 与 Slot scope 不会组装模型请求。 + +## 已知限制与暂缓事项 + +- **Pending interaction 是进程本地投影**——浏览器重连后,所属 Remote waterfall 必须重放仍未完成的请求。 diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 2af1804cd3..36676d1403 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -77,7 +77,6 @@ class PendingInteractionDomain { return () => { if (!active) return active = false - if (this.values.get(interaction.key) !== interaction) return this.values.delete(interaction.key) this.changed() } @@ -206,7 +205,7 @@ export class UiSession extends Service { private readonly pendingListeners = new Set<() => void>() /** Root source of pending UI interactions, independent from Controller snapshots. */ readonly pendingInteractions: HostObservable = { - getSnapshot: () => this.pendingSnapshot as SessionPendingInteractionSnapshot, + getSnapshot: () => this.pendingSnapshot, subscribe: (listener) => { this.pendingListeners.add(listener) return () => { this.pendingListeners.delete(listener) } @@ -294,7 +293,7 @@ export class UiSession extends Service { this.publishPendingInteractions() return () => { const index = this.pendingDomains.indexOf(runtimeDomain) - if (index !== -1) this.pendingDomains.splice(index, 1) + this.pendingDomains.splice(index, 1) this.publishPendingInteractions() } }, 'uiSession.registerPendingInteraction()') @@ -366,19 +365,18 @@ export class UiSession extends Service { private createMaterializedBinding(owner: SessionBinding): MaterializedBinding { const value = this.materialize(owner) - let releaseEffect: () => void | Promise = () => {} - const record: MaterializedBinding = { - owner, - value, - release: () => { void releaseEffect() }, - } - releaseEffect = owner.ctx.effect(() => () => { + const releaseEffect = owner.ctx.effect(() => () => { if (this.bindings.get(owner.sessionId) !== record) return this.bindings.delete(owner.sessionId) if (this.currentBinding !== value) return this.currentBinding = this.absent notifySubscribers(this.currentListeners, '[ui-session] current binding') }, `ui-session: binding ${owner.sessionId}`) + const record: MaterializedBinding = { + owner, + value, + release: () => { void releaseEffect() }, + } return record } diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 63293251fd..4045e4abb9 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -260,6 +260,19 @@ describe('UiSession bindings', () => { ) }) + it('releases cached bindings when the owning Client context stops', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const id = sessionId('s1') + bench.binding(id) + bench.select(id) + service.adapter.current.getSnapshot() + + await expect(ctx.fiber.dispose()).resolves.toBeUndefined() + await bench.release(id) + }) + it('rebuilds live bindings and removes only the disposed source contribution', () => { const ctx = new Context() const bench = createSessionsBench(ctx) @@ -370,10 +383,30 @@ describe('UiSession bindings', () => { })).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'") expect(service.adapter.current.getSnapshot()).toBe(before) }) + + it('releases partially rebuilt bindings when a later Session contribution fails', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + service.adapter.resolve(bench.binding(sessionId('s1')).sessionId) + service.adapter.resolve(bench.binding(sessionId('s2')).sessionId) + let calls = 0 + + expect(() => service.provide({ + props: ['partial'], + resolve: () => { + calls += 1 + if (calls === 2) throw new Error('second binding failed') + return { props: { partial: true } } + }, + })).toThrow('second binding failed') + expect(calls).toBe(2) + expect(service.adapter.resolve(sessionId('s1'))?.props).not.toHaveProperty('partial') + }) }) describe('UiSession pending interactions', () => { - it('publishes the highest-precedence exact object and removes each source independently', () => { + it('publishes the highest-precedence exact object and removes each source independently', async () => { const ctx = new Context() const bench = createSessionsBench(ctx) const service = createUiSession(ctx, bench) @@ -386,12 +419,16 @@ describe('UiSession pending interactions', () => { const registerQuestion = service.registerPendingInteraction( interaction => interaction.kind === 'plan-review' ? 2 : 1, ) + const registerBackground = service.registerPendingInteraction( + () => -1, + ) listener.mockClear() const approval = { key: 'approval:1', kind: 'approval', sessionId: id } const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id } const question = { key: 'question:1', kind: 'question', sessionId: id } const plan = { key: 'question:2', kind: 'plan-review', sessionId: id } + const background = { key: 'background:1', kind: 'background', sessionId: id } const removeApproval = registerApproval(approval) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval) const removeDuplicate = registerApproval(duplicate) @@ -400,7 +437,10 @@ describe('UiSession pending interactions', () => { expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question) const removePlan = registerQuestion(plan) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) + const removeBackground = registerBackground(background) + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) + removeBackground() removeQuestion() expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) removePlan() @@ -408,8 +448,10 @@ describe('UiSession pending interactions', () => { removeDuplicate() expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval) removeApproval() + removeApproval() expect(service.pendingInteractions.getSnapshot().has(id)).toBe(false) off() + await ctx.fiber.dispose() }) it('rejects duplicate keys and contains a failing aggregate subscriber', () => { diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0960a67bb9..401274034d 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -6,6 +6,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the Session root standard-props merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' +// Type-only: records the Workspace UI service dependency used below. +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' import { en, zh, type SidebarKey } from './locales.ts' diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index 2f9729cfbd..01538011c2 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.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 packages/client/ui-slots/README.md -README.md: 332bd092e8a4dec5a424acccce57c3dd0d8c27d8 -README.zh.md: 369776ea72c6f21184c0accd183aa9b409ee867a +README.md: 7d5bffae32f4f1056f54d02f4a949bc7ed34058d +README.zh.md: 8c242b94c88aa7414a6e32641269fb1e4ce4308c diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index 332bd092e8..7d5bffae32 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -19,7 +19,7 @@ The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are The store family (`defineStore` spec in / `StoreHandle` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding belongs to the render machinery; only the props-contract hook type (`SnapshotSelectorHook`) lives here. -`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; the runtime uses it for [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection), independently from ordinary entry versions. `renderer.ts` carries the installation contract (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; ui-renderer owns both the implementation and its plugin-lifecycle installation. +`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; `ui-renderer` uses it for [`ctx.slots.inject`](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md), independently from ordinary entry versions. `renderer.ts` carries the installation contract (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; ui-renderer owns both the implementation and its plugin-lifecycle installation. ## Model Experience diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index 369776ea72..8c242b94c8 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -19,7 +19,7 @@ chain-kind slot 会反转键控路由:条目自行提名,而不是由分发 store 家族(输入 `defineStore` 规范/输出 `StoreHandle`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于运行时包(引擎所属位置),并满足这里导出的 `DefineStore` 约定。引擎产物与 renderer host 约定携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React 钩子;钩子绑定属于渲染机制,只有 props 约定钩子类型(`SnapshotSelectorHook`)位于这里。 -`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与移除时递增;运行时将其用于 [`ctx.slots.inject`](../runtime/README.zh.md#slot-declaration-injection),且与普通条目版本相互独立。`renderer.ts` 携带安装约定(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;ui-renderer 同时持有实现及其插件生命周期安装。 +`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与移除时递增;`ui-renderer` 将其用于 [`ctx.slots.inject`](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md),且与普通条目版本相互独立。`renderer.ts` 携带安装约定(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;ui-renderer 同时持有实现及其插件生命周期安装。 ## 模型体验 diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 64baeaf3ee..866ac883e0 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -53,6 +53,7 @@ async function bench(declare = true) { const registerPendingInteraction = vi.fn((_precedence: (value: PendingQuestion) => number) => ( value: PendingQuestion, ) => { + _precedence(value) pending = [...pending, value] return () => { pending = pending.filter(candidate => candidate !== value) } }) diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index afa4be8aeb..9016b974d9 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -876,7 +876,7 @@ describe('plugin lifecycle', () => { ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin(TestSessions).await() - const conversationEvents = new UiConversation(ctx, ctx.sessions as never).events + const conversationEvents = new UiConversation(ctx, ctx.sessions).events ctx.slots.register({ name: 'root', children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } }, diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index c32359ce9e..767109dd98 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -57,6 +57,19 @@ describe('deriveGroups', () => { .toMatchObject({ pendingInteraction: 'plan-review', running: true }) }) + it.each(['approval', 'question'] as const)( + 'projects the %s pending-interaction kind', + (kind) => { + const awaiting = summary(kind, 10) + const attention: ReadonlyMap = new Map([[ + awaiting.id, + { key: `${kind}:1`, kind, sessionId: awaiting.id }, + ]]) + + expect(deriveFlat(list(awaiting), noArchive, attention)[0]?.pendingInteraction).toBe(kind) + }, + ) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups( diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index f3377f10e6..3419d73e89 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -91,13 +91,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', ], keyDomain: '', hookContext: '', @@ -108,14 +111,50 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation\', () => ctx.slots.register(\n { name: \'conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:62', + source: 'packages/client/ui-layout/src/client/index.ts:64', + }, + { + key: 'conversation.approval.detail', + kind: 'single', + scope: 'session', + summary: 'Optional detail for the Tool call correlated with an approval request.', + doc: 'Optional detail for the Tool call correlated with an approval request.', + registerOptions: [], + ownerProps: [ + '/** Stable identity handed to an optional approval-detail renderer. */\nexport interface ApprovalDetailOwnerProps {\n /** Tool call correlated with the request. */\n callId: CallId\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.composer\' (client-ui-approval), so it exists while that entry is mounted', + occupants: [ + 'client-ui-chat ApprovalCommand', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.approval.detail\', () => ctx.slots.register(\n { name: \'conversation.approval.detail\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-approval/src/client/contract/slots.ts:25', }, { key: 'conversation.chat.assistant-actions', kind: 'list', scope: 'session', - summary: 'Action strip attached to one finalized assistant message, rendered inside that message\'s IconActions row.', - doc: 'Action strip attached to one finalized assistant message, rendered\ninside that message\'s IconActions row. The chat entry owns the render\nsite and passes the addressed message identity; contributors add\nper-message actions without importing the conversation implementation.\nEntries render by ascending `order`.', + summary: 'Ordered actions for one finalized assistant message.', + doc: 'Ordered actions for one finalized assistant message. Each entry receives\nthe durable message id; a fresh `id` adds an action and reusing one replaces\nthat entry. With no entries, the standard action row remains unchanged.', registerOptions: [ { name: 'id', @@ -137,37 +176,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Owner currency of the assistant-message action strip: the durable identity\n * of the one finalized message the contributed actions address. Only finalized\n * messages reach this slot, so the id is always present.\n */\nexport interface AssistantActionOwnerProps {\n /** Stable identity carried from the `assistant/message` event. */\n messageId: MessageId\n}', + '/** Owner currency of finalized-assistant actions. */\nexport interface AssistantActionOwnerProps {\n messageId: MessageId\n}', ], ownerPropsReferences: [ 'MessageId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-message-feedback MessageFeedbackActions id \'feedback\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:148', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', }, { key: 'conversation.chat.commandview', kind: 'keyed', scope: 'session', - summary: 'The chat view\'s per-command row hole: keyed dispatch on the command name (`command/run.name`; a run-less cross-window node has none and always lands on the fallback).', - doc: 'The chat view\'s per-command row hole: keyed dispatch on the command\nname (`command/run.name`; a run-less cross-window node has none and\nalways lands on the fallback). Declared by the chat view entry; the\nrender site dispatches via `entryKey: name` with GenericCommandCard as\nthe `fallback` — a slash command renders durably with zero\nregistration, and a domain upgrades by registering one row component.', + summary: 'Command row keyed by the command name.', + doc: 'Command row keyed by the command name. The component receives the folded\ncommand lifecycle and linked compaction when present. Reusing a key\nreplaces that command renderer; an unoccupied key uses the generic card.', registerOptions: [ { name: 'key', @@ -177,36 +221,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Owner share of the per-command row slot: the frozen {@link CommandNode}\n * slice off the snapshot (cache-stable reference — memo premise). The node\n * carries the whole lifecycle (structured name/args, pairing id, and\n * outcome-or-executing). A successful domain command may also carry the\n * explicitly linked projection node needed to fold two log records into one\n * presentation row.\n */\nexport interface CommandRowOwnerProps {\n /** Folded command lifecycle node (run + optional done). */\n node: CommandNode\n /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */\n compaction?: CompactionSummaryNode\n}', + '/** Command-row owner share. */\nexport interface CommandRowOwnerProps {\n node: CommandNode\n compaction?: CompactionSummaryNode\n}', ], ownerPropsReferences: [ + 'Command', 'CommandNode', 'CompactionSummaryNode', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:185', }, { key: 'conversation.chat.node', kind: 'keyed', scope: 'session', - summary: 'Final business node renderer, dispatched by `ChatConversationViewNode.kind`.', - doc: 'Final business node renderer, dispatched by `ChatConversationViewNode.kind`.', + summary: 'Final Chat node renderer, keyed by `ChatNodeKind`.', + doc: 'Final Chat node renderer, keyed by `ChatNodeKind`. The component receives\nthe typed node, shared Chat actions, and Turn-data hook. Reusing a key\nreplaces that node renderer; a kind with no occupant renders no row.', registerOptions: [ { name: 'key', @@ -216,7 +266,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to one keyed Chat business renderer. */\nexport interface ChatNodeOwnerProps {\n /** Selected Tool call, when the shared details store names one. */\n selectedCallId?: CallId | undefined\n /** Session workspace root; Tool summaries display paths relative to it. */\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n /** Render a historical image group through the attachment slot. */\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n selectedCallId?: CallId | undefined\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', @@ -224,45 +274,50 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TurnTailOwnerProps', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run', hookContext: 'string', slotInject: 'ChatNodeTurnDataInjected', - declaredBy: 'an entry in \'conversation.view\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation UserMessageNodeView key \'user\'', - 'client-ui-conversation UserMessageNodeView key \'steering\'', - 'client-ui-conversation ContextMessageNodeView key \'context\'', - 'client-ui-conversation AssistantNodeView key \'assistant-step\'', - 'client-ui-conversation CommandNodeView key \'command\'', - 'client-ui-conversation ManualCompactionNodeView key \'manual-compaction\'', - 'client-ui-conversation CompactionNodeView key \'compaction\'', - 'client-ui-conversation RetryNodeView key \'model-retry\'', - 'client-ui-conversation TurnErrorNodeView key \'turn-error\'', - 'client-ui-conversation TurnMaxTokensNodeView key \'turn-max-tokens\'', - 'client-ui-conversation TurnTailNodeView key \'turn-tail\'', - 'client-ui-conversation UnknownNodeView key \'unknown\'', + 'client-ui-chat UserMessageNodeView key \'user\'', + 'client-ui-chat UserMessageNodeView key \'steering\'', + 'client-ui-chat ContextMessageNodeView key \'context\'', + 'client-ui-chat AssistantNodeView key \'assistant-step\'', + 'client-ui-chat CommandNodeView key \'command\'', + 'client-ui-chat ManualCompactionNodeView key \'manual-compaction\'', + 'client-ui-chat CompactionNodeView key \'compaction\'', + 'client-ui-chat RetryNodeView key \'model-retry\'', + 'client-ui-chat TurnErrorNodeView key \'turn-error\'', + 'client-ui-chat TurnMaxTokensNodeView key \'turn-max-tokens\'', + 'client-ui-chat TurnTailNodeView key \'turn-tail\'', + 'client-ui-chat UnknownNodeView key \'unknown\'', 'client-ui-goal GoalCommandInputView key \'command-input\'', 'client-ui-tool ToolCallTree key \'tool-call\'', 'client-ui-workflow-run WorkflowRunPanel key \'workflow-run\'', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:115', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:166', }, { key: 'conversation.chat.turnTail', kind: 'chain', scope: 'session', - summary: 'The completed Turn Node\'s extension chain, rendered before that Node\'s IconActions.', - doc: 'The completed Turn Node\'s extension chain, rendered before that Node\'s\nIconActions. Entries derive a match from the engine-owned Turn and\nclosing seq before mounting, so presentation components never mount\nonly to return null; an all-declined chain renders nothing.', + summary: 'Selector-routed extension before a completed Turn\'s action row.', + doc: 'Selector-routed extension before a completed Turn\'s action row. The\ncomponent receives the Turn, closing sequence, and file opener. The first\nselector that accepts the owner renders; an all-declined chain is empty.', registerOptions: [ { name: 'select', @@ -272,37 +327,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Owner currency of the chat view\'s turn-tail hole: the engine-owned Turn and\n * the closing assistant\'s anchor. Registrants read their own typed Turn data\n * and open files through the same opener the tool rows use.\n */\nexport interface TurnTailOwnerProps {\n /** Engine-owned closing Turn boundary. */\n turn: TurnLocation\n /** The closing assistant\'s seq — the anchor the tail renders under. */\n seq: number\n /**\n * Open a filesystem path through the Host (tool-row semantics; the chat\n * view resolves relative paths against the session cwd).\n */\n openFile: (path: string) => void\n}', + '/** Owner currency of the completed-Turn extension chain. */\nexport interface TurnTailOwnerProps {\n turn: TurnLocation\n seq: number\n openFile: (path: string) => void\n}', ], ownerPropsReferences: [ 'TurnLocation', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-deliverables ProducedFiles', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:140', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:191', }, { key: 'conversation.composer', kind: 'chain', scope: 'session', - summary: 'The composer takeover chain: entries are selector-routed replacements of the default InputBar.', - doc: 'The composer takeover chain: entries are selector-routed replacements\nof the default InputBar. Declared by this package\'s \'conversation\'\nentry; the owner dispatches the ComposerChainProps currency and\nrouting lives in entry selectors — new takeover kinds register with\nzero owner changes.', + summary: 'Selector-routed replacements for the current Session\'s resident composer.', + doc: 'Selector-routed replacements for the current Session\'s resident composer.', registerOptions: [ { name: 'select', @@ -312,55 +372,64 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Composer chain currency: what ConversationRoot dispatches at its\n * renderSlotChain site. The owner declares the currency only — never a\n * per-entry contract; takeover packages narrow it in their own selectors\n * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register\n * with zero owner changes.\n */\nexport interface ComposerChainProps {\n /** Effective domain-owned interaction selected for this Session. */\n pendingInteraction: PendingInteraction | undefined\n /** Current conversation facts for feature-owned takeover selectors. */\n session: ConversationSnapshot | undefined\n}', + '/** Owner values used to elect a composer takeover. */\nexport interface ComposerChainProps {\n /** Current Session identity used by temporary business-owned entries. */\n sessionId: SessionId | undefined\n /** Current Session lifecycle state, absent without a selected Session. */\n session: SessionSnapshot | undefined\n /** Effective business-owned interaction awaiting the user in this Session. */\n pendingInteraction: SessionPendingInteraction | undefined\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', - 'PendingInteraction', + 'SessionId', + 'SessionPendingInteraction', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation ApprovalPanel', + 'client-ui-approval ApprovalPanel', 'client-ui-subagent SubagentReadOnlyComposer', 'client-ui-user-questions QuestionComposer', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', }, { key: 'conversation.composer.bar', kind: 'single', scope: 'session-maybe', - summary: 'The default composer body: a single slot rendered as the composer chain\'s fallback (a real entry, not a chain rider, so a takeover election hides rather than unmounts it and the textarea DOM survives).', - doc: 'The default composer body: a single slot rendered as the composer\nchain\'s fallback (a real entry, not a chain rider, so a\ntakeover election hides rather than unmounts it and the textarea DOM\nsurvives). Session-maybe: the bar stays mounted across the\nno-session/session transition — the no-workspace hero renders the SAME\ntextarea DOM as a read-only Workspace-picker trigger instead of a\nparallel inert tree — with the machine hooks absent until a session is\ncurrent. InputBar registers\nhere from this package\'s apply; its machine state arrives through the\nstandard provide channel (useInput + inputActions), the keyboard\ncommand face through its own inject.', + summary: 'Resident composer body, including the no-Session inert state.', + doc: 'Resident composer body, including the no-Session inert state.', registerOptions: [], ownerProps: [ - '/**\n * Owner share of the composer-bar slot: ConversationRoot\'s layout-phase\n * inputs plus the input-region child-slot content it renders (the region\n * slots stay declared/rendered by the conversation entry; the bar hosts the\n * results as chrome).\n */\nexport interface ComposerBarOwnerProps {\n /** Hero = empty-state centered card; composer = resident bottom bar. */\n variant: \'hero\' | \'composer\'\n /**\n * A block another plugin raised for this session: the bar refuses input and\n * shows the blocker\'s reason as the placeholder, but — unlike `disabled` —\n * keeps the model seat live. Every block this contract has is one the user\n * clears by choosing a model, so locking that seat too would leave the\n * composer telling them to do the one thing it prevents.\n */\n blocked?: { readonly reason: string }\n /**\n * Inert no-workspace state: the bar locks message actions while preserving\n * its normal DOM so the Workspace pick transitions in place.\n */\n disabled?: boolean\n /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */\n workspacePickerOpen?: boolean\n /** Open the existing Workspace picker from the inert textarea. */ /* …truncated — full shape in source */', + '/** Owner share of the resident composer bar. */\nexport interface ComposerBarOwnerProps {\n /** Hero uses centered placement; composer uses the active bottom placement. */\n variant: \'hero\' | \'composer\'\n /** A feature-owned reason that makes message input inert while leaving model selection live. */\n blocked?: { readonly reason: string }\n /** Lock all message actions while preserving the resident textarea. */\n disabled?: boolean\n /** Whether the shared Workspace picker is expanded. */\n workspacePickerOpen?: boolean\n /** Open the Workspace picker from the inert textarea. */\n onRequestWorkspace?: () => void\n placeholder?: string\n /** Optional content rendered above the textarea. */\n accessory?: ReactNode\n /** Floating overlay content rendered inside the composer card. */\n overlay?: ReactNode\n /** Left-side input controls. */\n leftItems?: ReactNode\n /** Right-side input controls. */\n rightItems?: ReactNode\n /** Ambient content below the card. */\n footer?: ReactNode\n}', ], ownerPropsReferences: [ 'Workspace', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', ], keyDomain: '', hookContext: '', @@ -371,14 +440,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:245', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', }, { key: 'conversation.composer.dock', kind: 'list', scope: 'session', - summary: 'The band under the composer card, inside the bar\'s width column — the seat for an ambient readout about the conversation (the shipped stats line lives here).', - doc: 'The band under the composer card, inside the bar\'s width column — the\nseat for an ambient readout about the conversation (the shipped stats\nline lives here). Same InputZone owner share as the other\nregions. Anything the user must click belongs in the tool row instead\n(`conversation.input.left` / `.right`); anything needing its own line\nabove the card belongs in `conversation.input.dock`.', + summary: 'Ambient entries below the composer card.', + doc: 'Ambient entries below the composer card.', registerOptions: [ { name: 'id', @@ -400,77 +469,89 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation StatsLine id \'stats\'', + 'client-ui-chat StatsLine id \'stats\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', }, { key: 'conversation.details.tool', kind: 'single', scope: 'session', - summary: 'The body of the details panel for the tool call the user selected — one occupant, so taking it means rendering every tool\'s output, not just the ones you know.', - doc: 'The body of the details panel for the tool call the user selected —\none occupant, so taking it means rendering every tool\'s output, not just\nthe ones you know. The owner passes a frozen `block` whose two lifecycle\nforms must both be handled: branch on `\'kind\' in block` (a settled\n`ToolResultNode` has it, a still-running call does not), and treat\n`cwd` as display-only, for shortening workspace-rooted paths.\nA per-tool renderer belongs in the keyed `tool.call.toolview` seat\ninstead; this one is the whole panel.', + summary: 'Whole details-panel body for the selected Tool call.', + doc: 'Whole details-panel body for the selected Tool call. The component receives\nthe running or settled block and optional workspace root. A registration\nreplaces the shipped Tool details renderer; absence uses the raw fallback.', registerOptions: [], ownerProps: [ - '/** Owner currency of the details panel\'s Tool output renderer. */\nexport interface DetailsToolOwnerProps {\n /** Frozen selected call slice. */\n block: ToolCallBlock\n /** Session workspace root for card cwd and relative-path display. */\n cwd?: string | undefined\n}', + '/** Tool block rendered in the details panel. */\nexport interface DetailsToolOwnerProps {\n block: ToolCallBlock\n cwd?: string | undefined\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'details\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'details\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-tool ToolDetails', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:203', }, { key: 'conversation.hero.agentPreset', kind: 'single', scope: 'root', - summary: 'The agent-preset chip beside the workspace picker on the new-session screen.', - doc: 'The agent-preset chip beside the workspace picker on the new-session\nscreen. Root scope: no session exists yet, so the choice is staged for\nthe next one rather than applied to a current one.', + summary: 'Agent-preset control staged for a New Session.', + doc: 'Agent-preset control staged for a New Session.', registerOptions: [], ownerProps: [ - '/** Owner share of the hero agent-preset chip: the shell supplies nothing. */\nexport interface HeroAgentPresetOwnerProps {\n /** Marker field: the chip owns its own roster, staging, and menu state. */\n children?: never\n}', + '/** Owner share of the Hero agent-preset control. */\nexport interface HeroAgentPresetOwnerProps {\n /** Marker field: the occupant owns its roster and staged selection. */\n children?: never\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -481,22 +562,24 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', }, { key: 'conversation.hero.brand.mark', kind: 'single', scope: 'root', - summary: 'Brand mark leading the blank-session headline.', - doc: 'Brand mark leading the blank-session headline. Declared by this\npackage\'s `conversation` entry; the shell supplies a fish fallback.', + summary: 'Brand mark shown before the blank-session headline.', + doc: 'Brand mark shown before the blank-session headline.', registerOptions: [], ownerProps: [ - '/** Presentation props supplied to the blank-session brand-mark occupant. */\nexport interface HeroBrandMarkOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n /** Host CSS class for preserving the default hero mark color and hover motion. */\n className?: string | undefined\n}', + '/** Presentation props supplied to the blank-session brand mark. */\nexport interface HeroBrandMarkOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n /** Host class preserving the surrounding mark geometry. */\n className?: string | undefined\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -507,24 +590,26 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:82', }, { key: 'conversation.hero.workspace', kind: 'single', scope: 'root', - summary: 'The hero-phase Workspace picker hole: rendered by ConversationRoot while the session is blank (picking another workspace switches to that workspace\'s blank session, draft carried).', - doc: 'The hero-phase Workspace picker hole: rendered by ConversationRoot\nwhile the session is blank (picking another workspace switches to that\nworkspace\'s blank session, draft carried). Root scope: the picker\nreads the global workspace list.', + summary: 'Workspace picker shown by the blank-session Hero.', + doc: 'Workspace picker shown by the blank-session Hero.', registerOptions: [], ownerProps: [ - '/** Owner share common to the hero / New-Session Workspace pickers. */\nexport interface EmptyWorkspaceOwnerProps {\n open: boolean\n anchorRef?: RefObject\n /** Currently active workspace (renders a trailing check in the picker list). */\n selectedId?: WorkspaceId | undefined\n onPick: (workspaceId: WorkspaceId) => void\n onClose: () => void\n}', + '/** Owner share common to blank-session Workspace pickers. */\nexport interface EmptyWorkspaceOwnerProps {\n open: boolean\n anchorRef?: RefObject\n /** Currently selected Workspace, when available. */\n selectedId?: WorkspaceId | undefined\n onPick: (workspaceId: WorkspaceId) => void\n onClose: () => void\n}', ], ownerPropsReferences: [ 'Workspace', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -535,7 +620,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:80', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -549,8 +634,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -568,24 +655,27 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'conversation.input.attachments', kind: 'single', scope: 'session-maybe', - summary: 'Optional draft-image rail, drop target, and preview surface inside the composer.', - doc: 'Optional draft-image rail, drop target, and preview surface inside the composer.', + summary: 'Optional draft-image rail and drop target.', + doc: 'Optional draft-image rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the Conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', 'DraftAttachmentId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', ], keyDomain: '', hookContext: '', @@ -596,14 +686,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:247', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', }, { key: 'conversation.input.dock', kind: 'list', scope: 'session', - summary: 'A full-width row of its own, stacked above the composer card — the seat for anything that needs a line to itself (queue rows, a todo strip, a goal bar).', - doc: 'A full-width row of its own, stacked above the composer card — the seat\nfor anything that needs a line to itself (queue rows, a todo strip, a\ngoal bar). Pick this over the three seats below when your content wraps\nor carries prose; pick `conversation.composer.dock` for an ambient\nreadout under the card, and `conversation.input.left` /\n`.right` for a small control INSIDE the card\'s tool row.\nRead only `session`/`input` off the owner share (InputZone) —\nboth are point-in-time snapshots re-rendered for you, never subscribe.', + summary: 'Full-width entries above the composer card.', + doc: 'Full-width entries above the composer card.', registerOptions: [ { name: 'id', @@ -625,20 +715,25 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -651,14 +746,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:86', }, { key: 'conversation.input.left', kind: 'list', scope: 'session', - summary: 'The left end of the tool row INSIDE the composer card, after the resident chrome (access mode, plan, attach) — the seat for a small always-visible control.', - doc: 'The left end of the tool row INSIDE the composer card, after the\nresident chrome (access mode, plan, attach) — the seat for a small\nalways-visible control. Entries sit beside that chrome, never replace\nit. Same InputZone owner share; use `.right` for a control that\nbelongs next to the send button, and the docks for anything taller than\none row.', + summary: 'Compact controls at the left of the composer tool row.', + doc: 'Compact controls at the left of the composer tool row.', registerOptions: [ { name: 'id', @@ -680,20 +775,25 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -702,27 +802,32 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:223', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', }, { key: 'conversation.input.model', kind: 'single', scope: 'session', - summary: 'The named model-select seat at the right end of the composer tool row, left of the send button — one occupant, so taking it means rendering the whole model affordance yourself.', - doc: 'The named model-select seat at the right end of the composer tool row,\nleft of the send button — one occupant, so taking it means rendering the\nwhole model affordance yourself. Same `locked`-only owner share and same\nrenders-nothing-while-empty contract as the plan seat. Note the composer\ndeliberately keeps this seat LIVE while it refuses text for a\nmodel-related block: every such block is one the user clears by picking\na model here.', + summary: 'Model selector inside the composer tool row.', + doc: 'Model selector inside the composer tool row.', registerOptions: [], ownerProps: [ - '/**\n * Owner share of the two named composer control seats (plan / model): the\n * bar passes its disable state; the filling entry owns everything else.\n */\nexport interface InputControlOwnerProps {\n /** Session-removed lock (the bar\'s chrome disable state). */\n locked: boolean\n}', + '/** Owner share of the named plan and model controls. */\nexport interface InputControlOwnerProps {\n /** Whether the composer currently refuses interaction. */\n locked: boolean\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -733,14 +838,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:271', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', }, { key: 'conversation.input.overlay', kind: 'list', scope: 'session', - summary: 'The InputBar floating overlay anchor: MenuView (this package) and the popupSelect shell (ui-commands) contribute list entries; each reads its own store and renders null while closed.', - doc: 'The InputBar floating overlay anchor: MenuView (this package) and the\npopupSelect shell (ui-commands) contribute list entries; each reads its\nown store and renders null while closed. Declared (children table) by\nui-conversation\'s composer entry; the anchor hides with the input\nunder a takeover.', + summary: 'Floating entries rendered inside the resident composer card.', + doc: 'Floating entries rendered inside the resident composer card.', registerOptions: [ { name: 'id', @@ -764,13 +869,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -782,27 +892,32 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-input-trigger/src/client/slots.ts:24', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:88', }, { key: 'conversation.input.plan', kind: 'single', scope: 'session', - summary: 'The named plan-status seat in the composer tool row, immediately right of the access-mode control — one occupant, so taking it means rendering the plan affordance yourself.', - doc: 'The named plan-status seat in the composer tool row, immediately right\nof the access-mode control — one occupant, so taking it means rendering\nthe plan affordance yourself. The owner passes only `locked` (see\nInputControlOwnerProps): honour it by refusing interaction, and\ntake everything else from the framework session kit or your own inject.\nUnoccupied, the seat renders nothing at all — the bar paints no\nplaceholder, so an absent plan plugin costs no layout.', + summary: 'Plan control inside the composer tool row.', + doc: 'Plan control inside the composer tool row.', registerOptions: [], ownerProps: [ - '/**\n * Owner share of the two named composer control seats (plan / model): the\n * bar passes its disable state; the filling entry owns everything else.\n */\nexport interface InputControlOwnerProps {\n /** Session-removed lock (the bar\'s chrome disable state). */\n locked: boolean\n}', + '/** Owner share of the named plan and model controls. */\nexport interface InputControlOwnerProps {\n /** Whether the composer currently refuses interaction. */\n locked: boolean\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -813,14 +928,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:261', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', }, { key: 'conversation.input.right', kind: 'list', scope: 'session', - summary: 'The right end of the same tool row, before the primary send button — the seat for a control the user reaches on the way to sending (the model select sits in its own named seat just left of here).', - doc: 'The right end of the same tool row, before the primary send button —\nthe seat for a control the user reaches on the way to sending (the\nmodel select sits in its own named seat just left of here). Same\nInputZone owner share and the same one-row height budget as\n`conversation.input.left`.', + summary: 'Compact controls before the composer submit action.', + doc: 'Compact controls before the composer submit action.', registerOptions: [ { name: 'id', @@ -842,20 +957,25 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -864,59 +984,68 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', }, { key: 'conversation.message.images', kind: 'single', scope: 'session', - summary: 'Optional renderer for one consecutive group of durable message images.', - doc: 'Optional renderer for one consecutive group of durable message images.', + summary: 'Renderer for one consecutive group of durable message images.', + doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Consecutive image blocks rendered as one gallery. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized durable image loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Message-side alignment. */\n align: \'start\' | \'end\'\n}', + '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n loadImage: (attachment: ImageAttachmentRef) => Promise\n align: \'start\' | \'end\'\n}', ], ownerPropsReferences: [ 'ImageAttachmentRef', - 'Message', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.view\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-attachment MessageImages', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:124', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:179', }, { key: 'conversation.session', kind: 'single', scope: 'session', - summary: 'The entire body of one session: taking this seat means rendering that session\'s conversation yourself.', - doc: 'The entire body of one session: taking this seat means rendering that\nsession\'s conversation yourself. The occupant also owns the per-session\ndraft mirror and the active view ring, so a replacement inherits both\nduties and an empty one leaves a blank session pane — nothing here\ndegrades gracefully. To ADD rather than replace, take a seat inside the\nflow instead: `conversation.view` for a whole tab, the input regions for\ncomposer chrome.', + summary: 'Strict per-Session Conversation body.', + doc: 'Strict per-Session Conversation body.', registerOptions: [], ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -927,25 +1056,30 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:71', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:54', }, { key: 'conversation.session.header', kind: 'single', scope: 'session', - summary: 'The strip above the session\'s scrollport: title, view tabs, and the action row.', - doc: 'The strip above the session\'s scrollport: title, view tabs, and the\naction row. Taking this seat means rendering all three yourself, and it\nalso collapses `conversation.session.header.actions` — that additive\nseat is declared by whoever occupies this one, so replacing the header\ntakes every action entry down with it.', + summary: 'Strict per-Session title, actions, and View navigation.', + doc: 'Strict per-Session title, actions, and View navigation.', registerOptions: [], ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -956,14 +1090,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:79', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:56', }, { key: 'conversation.session.header.actions', kind: 'list', scope: 'session', - summary: 'One button in the session header\'s action row — the additive way to put a per-session control beside the title without replacing the header.', - doc: 'One button in the session header\'s action row — the additive way to put\na per-session control beside the title without replacing the header.\nEntries render by ascending `order`; negative values are reserved for\nstatic session context that precedes interactive actions. The owner\npasses nothing: everything a control needs comes from the framework\nsession kit (`sessionId`, `useSession`, `useInput`, `inputActions`) and\nfrom the registrant\'s own inject face, so an empty owner share means\nself-sufficient, not starved.', + summary: 'Title-adjacent Session actions in ascending order.', + doc: 'Title-adjacent Session actions in ascending order.', registerOptions: [ { name: 'id', @@ -985,17 +1119,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Header actions derive their state from the standard session/global kit. */\nexport interface ConversationHeaderActionOwnerProps {}', + '/** Header actions derive their state from standard Session props. */\nexport interface ConversationHeaderActionOwnerProps {\n /** Marker field: entries receive no owner-specific values. */\n children?: never\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -1007,29 +1146,34 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:64', }, { key: 'conversation.session.header.lineage', kind: 'single', scope: 'session', - summary: 'One breadcrumb title and its lineage controls.', - doc: 'One breadcrumb title and its lineage controls. The render site keeps\nthe ordinary title as fallback; an occupant receives plain title data\nand may replace a subagent title with one combined navigation control.', + summary: 'Optional replacement for one Session breadcrumb title.', + doc: 'Optional replacement for one Session breadcrumb title.', registerOptions: [], ownerProps: [ - '/** Plain breadcrumb data handed to the optional lineage renderer. */\nexport interface ConversationHeaderLineageOwnerProps {\n /** Session represented by this breadcrumb title. */\n lineageSessionId: SessionId\n /** Display title available to a renderer that combines the title with a control. */\n displayTitle: string\n /** Navigate to an ancestor title when its combined control is clicked. */\n openTitle?: () => void\n}', + '/** Plain breadcrumb data handed to the optional lineage renderer. */\nexport interface ConversationHeaderLineageOwnerProps {\n /** Session represented by this breadcrumb title. */\n lineageSessionId: SessionId\n /** Display title available to a combined title/control renderer. */\n displayTitle: string\n /** Navigate to an ancestor title when present. */\n openTitle?: () => void\n}', ], ownerPropsReferences: [ 'SessionId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -1040,14 +1184,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:85', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:58', }, { key: 'conversation.session.header.utilities', kind: 'list', scope: 'session', - summary: 'Right-aligned Session utilities kept outside the title-adjacent action group, so an optional utility cannot reorder session context or lineage.', - doc: 'Right-aligned Session utilities kept outside the title-adjacent action\ngroup, so an optional utility cannot reorder session context or lineage.', + summary: 'Right-aligned Session utilities in ascending order.', + doc: 'Right-aligned Session utilities in ascending order.', registerOptions: [ { name: 'id', @@ -1069,17 +1213,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Header actions derive their state from the standard session/global kit. */\nexport interface ConversationHeaderActionOwnerProps {}', + '/** Header actions derive their state from standard Session props. */\nexport interface ConversationHeaderActionOwnerProps {\n /** Marker field: entries receive no owner-specific values. */\n children?: never\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -1090,14 +1239,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', }, { key: 'conversation.view', kind: 'list', scope: 'session', - summary: 'The conversation view ring: one list entry per view tab (chat here; trajectory/waterfall from ui-trajectory), rendered one-at-a-time by the session body via `only: `.', - doc: 'The conversation view ring: one list entry per view tab (chat here;\ntrajectory/waterfall from ui-trajectory), rendered one-at-a-time by\nthe session body via `only: `. Declared by this package\'s\nbody entry (declaring is claiming). Session scope: views read the\nconversation snapshot through the standard kit.', + summary: 'Registered Conversation target Views, rendered one at a time.', + doc: 'Registered Conversation target Views, rendered one at a time.', registerOptions: [ { name: 'id', @@ -1119,29 +1268,36 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * View-slot owner share: the cross-view inspect handoff (otherwise views need\n * nothing from the render site — sessionId and the snapshot hook arrive as\n * framework-standard props; tool rows go through each view\'s own declared\n * toolview hole).\n */\nexport interface ConvViewOwnerProps {\n /** One-shot inspect request from another view (chat\'s Inspect button); null when idle. */\n inspect?: { callId: CallId } | null\n /** Acknowledge the inspect request once applied (clears the store field). */\n onInspectDone?: () => void\n}', + '/** Conversation View entries obtain their data from registered standard hooks. */\nexport interface ConvViewOwnerProps {\n /** Focus request addressed to the selected View. */\n viewRequest: import(\'./views.ts\').ConversationViewRequest | null\n /** Select a View and address one opaque focus identity to it. */\n openView: (view: string, focus: string) => void\n /** Acknowledge the current one-shot focus request. */\n completeViewRequest: () => void\n}', + ], + ownerPropsReferences: [ + 'ConversationViewRequest', ], - ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation.session\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation ChatView id \'chat\'', + 'client-ui-chat ChatView id \'chat\'', 'client-ui-trajectory TrajectoryView id \'trajectory\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:113', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:76', }, { key: 'details', @@ -1155,24 +1311,29 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation DetailsPanel', + 'client-ui-chat DetailsPanel', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'details\', () => ctx.slots.register(\n { name: \'details\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:72', + source: 'packages/client/ui-layout/src/client/index.ts:74', }, { key: 'root', @@ -1186,8 +1347,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1198,7 +1361,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'root\', () => ctx.slots.register(\n { name: \'root\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/runtime/src/client/slots.ts:41', + source: 'packages/client/ui-renderer/src/client/registry.ts:43', }, { key: 'settings.action', @@ -1231,8 +1394,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1243,7 +1408,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.action\', () => ctx.slots.register(\n { name: \'settings.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:35', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:36', }, { key: 'settings.close', @@ -1257,8 +1422,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1269,7 +1436,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.close\', () => ctx.slots.register(\n { name: \'settings.close\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:41', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:42', }, { key: 'settings.general.item', @@ -1302,8 +1469,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1318,7 +1487,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.general.item\', () => ctx.slots.register(\n { name: \'settings.general.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:88', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:89', }, { key: 'settings.header', @@ -1332,8 +1501,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1344,7 +1515,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.header\', () => ctx.slots.register(\n { name: \'settings.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:29', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:30', }, { key: 'settings.onboarding', @@ -1377,8 +1548,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1390,7 +1563,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:73', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:74', }, { key: 'settings.plugin.item', @@ -1411,8 +1584,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', @@ -1458,8 +1633,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1471,7 +1648,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugins.tab\', () => ctx.slots.register(\n { name: \'settings.plugins.tab\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:62', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:63', }, { key: 'settings.section', @@ -1504,8 +1681,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1519,7 +1698,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.section\', () => ctx.slots.register(\n { name: \'settings.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:53', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:54', }, { key: 'settings.trigger', @@ -1533,8 +1712,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1545,7 +1726,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.trigger\', () => ctx.slots.register(\n { name: \'settings.trigger\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:23', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:24', }, { key: 'shell.overlay', @@ -1576,8 +1757,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1586,7 +1769,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:83', + source: 'packages/client/ui-layout/src/client/index.ts:85', }, { key: 'sidebar', @@ -1600,8 +1783,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1612,7 +1797,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar\', () => ctx.slots.register(\n { name: \'sidebar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:49', + source: 'packages/client/ui-layout/src/client/index.ts:51', }, { key: 'sidebar.brand.mark', @@ -1626,8 +1811,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1652,8 +1839,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1697,8 +1886,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1723,8 +1914,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1749,8 +1942,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1775,8 +1970,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1811,13 +2008,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Wire', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, read, skill, todo_write, web_fetch, web_search, write', hookContext: '', @@ -1867,13 +2069,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'CordisDynamicPluginRunId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e450f379cd..bb3e9918ca 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2339,18 +2339,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'userQuestions', - summary: '`ctx.userQuestions`: one active UI provider plus an `ask()` API.', - description: '`ctx.userQuestions`: one active UI provider plus an `ask()` API.', + summary: '`ctx.userQuestions`: validation plus the scoped answerer waterfall.', + description: '`ctx.userQuestions`: validation plus the scoped answerer waterfall.', methods: [ - { - signature: 'registerProvider(provider: UserQuestionProvider): () => void', - description: 'Register the UI provider. Only one provider may be active in a context.', - parameters: [{ name: 'provider', description: 'UI-side implementation that collects answers.' }], - returns: 'Disposer that unregisters this provider.', - }, { signature: 'async ask(request: AskUserQuestionRequest): Promise', - description: 'Ask the active UI provider and wait for the user\'s answer.\n\nWhen a caller supplies an agent, human interaction is valid only for the exact live runtime root. Runtime ownership, not durable session lineage, decides this boundary: an owned child has no human answerer and would block forever, while a lineage-bearing session resumed as a new runtime root may ask normally.', + description: 'Ask the scoped answerer waterfall and wait for the user\'s answer.\n\nWhen a caller supplies an agent, human interaction is valid only for the exact live runtime root. Runtime ownership, not durable session lineage, decides this boundary: an owned child has no human answerer and would block forever, while a lineage-bearing session resumed as a new runtime root may ask normally.', parameters: [{ name: 'request', description: 'Questions, owner agent, and abort signal.' }], returns: 'The answer chosen or typed by the human.', throws: ['{UserQuestionError} code `ASK_ABORTED` when the supplied signal is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent is not the registry\'s exact live instance, or `DELEGATED_CALLER` when that live agent is owned by another agent.'], @@ -3185,11 +3179,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AskUserQuestionRequest', - declaration: 'export interface AskUserQuestionRequest {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}', + declaration: 'export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {\n}', }, { name: 'AskUserQuestionRequestEvent', - declaration: 'export interface AskUserQuestionRequestEvent {\n questions: AskUserQuestionItem[];\n agent: Agent;\n signal?: AbortSignal;\n}', + declaration: 'export interface AskUserQuestionRequestEvent {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}', }, { name: 'AssembleContext', @@ -4363,6 +4357,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ServerResponse', declaration: 'export interface ServerResponse {\n type: \'server-response\';\n rpcId: RpcId;\n result: RpcResult;\n}', }, + { + name: 'Session', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + }, { name: 'SessionAddress', declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};', @@ -4683,6 +4681,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionStartSource', declaration: 'export type SessionStartSource = \'startup\' | \'resume\' | \'clear\' | \'compact\';', }, + { + name: 'SessionSummary', + declaration: 'export interface SessionSummary {\n readonly sessionId: SessionId;\n readonly updatedAt: number;\n readonly running: boolean;\n readonly blank: boolean;\n readonly parentSessionId?: SessionId;\n readonly origin?: \'subagent\';\n readonly cwd?: string;\n readonly agentPreset?: string;\n readonly projections?: SessionProjectionsBlock;\n}', + }, + { + name: 'SessionSurface', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -5047,6 +5053,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\';', }, + { + name: 'SurfaceIntent', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', + }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', @@ -5411,10 +5421,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'UserMessage', declaration: 'export interface UserMessage extends Message {\n readonly role: \'user\';\n}', }, - { - name: 'UserQuestionProvider', - declaration: 'export interface UserQuestionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', - }, { name: 'VerifiedWebhookDelivery', declaration: 'export interface VerifiedWebhookDelivery {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf;\n readonly receivedAt: number;\n}', diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index b51722d089..1d5d136426 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -8,6 +8,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index a888168994..f98b93bb0c 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -256,9 +256,11 @@ describe('stores', () => { it('storeOf guards: before renderRoot, and for storeless entries', async () => { const runtime = await runtimeWithFrame() runtime.slots.register({ name: 'trt.panel' }, () => null) + runtime.slots.register({ name: 'trt.chat', store: createSuiteStore() }, () => null) expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/) runtime.renderRoot() expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/) + expect(() => runtime.storeOf('trt.chat', 'missing')).toThrow(/no live Session binding/) await runtime.dispose() }) diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index af8408917c..90f8a626e2 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.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 packages/typert/protocol/README.md -README.md: 84f9f1b31cca35cffce5dc5fc84db1017adaad08 -README.zh.md: 97e4734a7292f0a305ff8ed00ff1d72d1a53b5e9 +README.md: 3d8df3808a378cae115edb938160f73766f68747 +README.zh.md: 15247f37b0c08c1e0e9de38d159e5e7aea945109 diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index b3c081b128..fe01da8e7c 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.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 packages/typert/registry/README.md -README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 -README.zh.md: 62c5a2141de9ccdd564654658a464d721b77f97d +README.md: 2f3056735f5352c06b1e115a18c7ad1968ff90ae +README.zh.md: 770d3111fc65b08ee5d30ead6c3e6899630dd3e0 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 299d0eec76..d8bed47236 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -148,7 +148,9 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', + uiSession: 'client-side Session source adapter — packages/client/ui-session/README.md owns the API', uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API', + uiWorkspace: 'client-side Workspace navigation adapter — packages/client/ui-workspace/README.md owns the API', settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API', settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API', chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API', diff --git a/scripts/package-graph.spec.ts b/scripts/package-graph.spec.ts new file mode 100644 index 0000000000..fecbccedfe --- /dev/null +++ b/scripts/package-graph.spec.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectPackageGraph } from './package-graph.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(packages: Readonly>): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-graph-')) + roots.push(root) + for (const [name, dependencies] of Object.entries(packages)) { + const directory = join(root, 'packages', 'client', name) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'package.json'), `${JSON.stringify({ + name: `@deepseek-ai/dsh-${name}`, + peerDependencies: Object.fromEntries(dependencies.map(dependency => [ + `@deepseek-ai/dsh-${dependency}`, + 'workspace:^', + ])), + }, null, 2)}\n`) + } + return root +} + +describe('collectPackageGraph', () => { + it('orders packages after their dependencies', () => { + const root = fixture({ application: ['feature'], feature: ['foundation'], foundation: [] }) + + expect(collectPackageGraph(root, ['client'], 'fixture').map(pkg => pkg.short)) + .toEqual(['foundation', 'feature', 'application']) + }) + + it('keeps a dependency cycle together and before its consumers', () => { + const root = fixture({ consumer: ['left'], left: ['right'], right: ['left'], foundation: [] }) + + expect(collectPackageGraph(root, ['client'], 'fixture').map(pkg => pkg.short)) + .toEqual(['foundation', 'left', 'right', 'consumer']) + }) + + it('rejects a missing in-repo peer', () => { + const root = fixture({ consumer: ['missing'] }) + + expect(() => collectPackageGraph(root, ['client'], 'fixture')) + .toThrow('fixture: @deepseek-ai/dsh-consumer references missing in-repo peer @deepseek-ai/dsh-missing') + }) +}) diff --git a/scripts/package-graph.ts b/scripts/package-graph.ts index 0853b5c1ca..3d12a904b1 100644 --- a/scripts/package-graph.ts +++ b/scripts/package-graph.ts @@ -25,11 +25,12 @@ export interface PackageGraphNode { } /** - * Read every harness package manifest and return dependency-safe graph nodes. + * Read every harness package manifest and return dependency-first graph nodes. * @param root - absolute repository root. * @param groupOrder - caller-specific tiebreak order for packages in the same dependency layer. * @param gate - command name used in structural error messages. - * @returns package nodes ordered after all of their in-repo dependencies. + * @returns package nodes ordered after their in-repo dependencies, except for + * stable back edges inside a dependency cycle. */ export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] { const packages: PackageGraphNode[] = [] @@ -57,14 +58,28 @@ export function collectPackageGraph(root: string, groupOrder: readonly string[], } function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], gate: string): PackageGraphNode[] { - const remaining = new Map(packages.map(pkg => [pkg.short, pkg])) + const byName = new Map(packages.map(pkg => [pkg.short, pkg])) + for (const pkg of packages) { + for (const dependency of pkg.deps) { + if (!byName.has(dependency)) { + throw new Error(`${gate}: ${pkg.name} references missing in-repo peer ${SCOPE}${dependency}`) + } + } + } + const remaining = new Map(byName) const placed = new Set() const out: PackageGraphNode[] = [] while (remaining.size > 0) { - const ready = [...remaining.values()] + let ready = [...remaining.values()] .filter(pkg => pkg.deps.every(dep => placed.has(dep))) .sort((a, b) => comparePackages(a, b, groupOrder)) - if (ready.length === 0) throw new Error(`${gate}: dependency cycle among ${[...remaining.keys()].join(', ')}`) + if (ready.length === 0) { + const cycle = sinkCycles(remaining) + .map(component => component.sort((a, b) => comparePackages(a, b, groupOrder))) + .sort((a, b) => comparePackages(a[0], b[0], groupOrder))[0] + if (cycle === undefined) throw new Error(`${gate}: could not order package dependency graph`) + ready = cycle + } for (const pkg of ready) { out.push(pkg) placed.add(pkg.short) @@ -74,6 +89,66 @@ function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], g return out } +type PackageGraphComponent = [PackageGraphNode, ...PackageGraphNode[]] + +function sinkCycles(remaining: ReadonlyMap): PackageGraphComponent[] { + let nextIndex = 0 + const indices = new Map() + const lowLinks = new Map() + const stack: PackageGraphNode[] = [] + const stacked = new Set() + const components: PackageGraphComponent[] = [] + + const visit = (pkg: PackageGraphNode): void => { + const index = nextIndex + nextIndex += 1 + indices.set(pkg.short, index) + lowLinks.set(pkg.short, index) + stack.push(pkg) + stacked.add(pkg.short) + for (const dependency of pkg.deps) { + const target = remaining.get(dependency) + if (target === undefined) continue + if (!indices.has(target.short)) { + visit(target) + lowLinks.set(pkg.short, Math.min(requiredValue(lowLinks, pkg.short), requiredValue(lowLinks, target.short))) + } else if (stacked.has(target.short)) { + lowLinks.set(pkg.short, Math.min(requiredValue(lowLinks, pkg.short), requiredValue(indices, target.short))) + } + } + if (lowLinks.get(pkg.short) !== indices.get(pkg.short)) return + const first = stack.pop() + if (first === undefined) throw new Error('package graph traversal lost its active component') + stacked.delete(first.short) + const component: PackageGraphComponent = [first] + let member = first + while (member !== pkg) { + const next = stack.pop() + if (next === undefined) throw new Error('package graph traversal lost its active component') + stacked.delete(next.short) + component.push(next) + member = next + } + components.push(component) + } + + for (const pkg of remaining.values()) { + if (!indices.has(pkg.short)) visit(pkg) + } + return components.filter((component) => { + const names = new Set(component.map(pkg => pkg.short)) + const first = component[0] + const cyclic = component.length > 1 || first.deps.includes(first.short) + return cyclic && component.every(pkg => pkg.deps.every(dep => !remaining.has(dep) || names.has(dep))) + }) +} + +function requiredValue(values: ReadonlyMap, key: K): V { + const value = values.get(key) + if (value === undefined) throw new Error('package graph traversal lost an indexed node') + return value +} + function comparePackages(a: PackageGraphNode, b: PackageGraphNode, groupOrder: readonly string[]): number { const groupA = groupOrder.indexOf(a.group) const groupB = groupOrder.indexOf(b.group) diff --git a/scripts/run-oxlint.spec.ts b/scripts/run-oxlint.spec.ts index 85628382e8..07ee0b3954 100644 --- a/scripts/run-oxlint.spec.ts +++ b/scripts/run-oxlint.spec.ts @@ -18,7 +18,7 @@ describe('Oxlint invocation', () => { it('uses location-preserving diagnostics in CI', () => { expect(resolveOxlintInvocation(['.'], { CI: 'true', DSH_OXLINT_THREADS: '4' })).toEqual({ - args: ['.', '--format=unix', '--threads=4'], + args: ['.', '--format=default', '--threads=4'], env: { CI: 'true', DSH_OXLINT_THREADS: '4', GOMAXPROCS: '4' }, }) }) diff --git a/scripts/run-oxlint.ts b/scripts/run-oxlint.ts index bcbddb5011..335bf213ed 100644 --- a/scripts/run-oxlint.ts +++ b/scripts/run-oxlint.ts @@ -32,7 +32,7 @@ export interface OxlintInvocation { */ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.ProcessEnv): OxlintInvocation { const resolvedArgs = [...args] - if (env.CI === 'true' && !hasOutputFormat(args)) resolvedArgs.push('--format=unix') + if (env.CI === 'true' && !hasOutputFormat(args)) resolvedArgs.push('--format=default') const raw = env.DSH_OXLINT_THREADS if (raw === undefined || raw === '') return { args: resolvedArgs, env: { ...env } } const parsed = Number.parseInt(raw, 10) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 05ff95154e..06cce11143 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -876,11 +876,6 @@ "symbol": "AskUserQuestionAnswer", "source": "packages/interaction/user-questions/src/types.ts" }, - { - "doc": "docs/subsystems/user-questions.md", - "symbol": "UserQuestionProvider", - "source": "packages/interaction/user-questions/src/index.ts" - }, { "doc": "docs/subsystems/user-questions.md", "symbol": "UserQuestionError", @@ -1980,6 +1975,11 @@ "doc": "docs/subsystems/feedback.md", "symbol": "MessageFeedbackDeleteResult", "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "packages/client/ui-conversation/README.md", + "symbol": "ComposerChainProps", + "source": "packages/client/ui-conversation/src/client/contract/slots.ts" } ] } From 828cd3f7b15860297d92ff9523b784e17634e796 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:23:58 +0800 Subject: [PATCH 200/248] fix(client): avoid cyclic UI service type imports --- packages/client/ui-conversation/src/client/apply.ts | 3 +-- packages/client/ui-sidebar/src/client/index.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0edc19c42f..e019ec995b 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -9,7 +9,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { @@ -97,7 +96,7 @@ function concreteConversation(ctx: Context): ConversationController { export function apply(ctx: Context): void { const sessions = ctx.sessions const slots = ctx.slots - const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation + const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 401274034d..2103f133e2 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -6,8 +6,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the Session root standard-props merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' -// Type-only: records the Workspace UI service dependency used below. -import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' import { en, zh, type SidebarKey } from './locales.ts' @@ -39,7 +37,7 @@ export const inject = ['slots', 'layout', 'uiWorkspace', 'locale'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation + const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') const injectProps = (): SidebarRootInjected => ({ From 0b6269b50cafdf50eb084601eec14c1547521cef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:32:16 +0800 Subject: [PATCH 201/248] fixup! refactor(interaction): move Approval and Question into UI owners --- .../interaction/user-questions/tests/user-questions.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index 87e5666593..02ee19ea26 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -148,7 +148,7 @@ describe('UserQuestionService', () => { name: 'UserQuestionError', code: 'ASK_CANCELLED', }) - ctx.userQuestions.registerProvider({ ask: () => Promise.reject(transported) }) + registerAnswerer(ctx, { ask: () => Promise.reject(transported) }) const rejection = await ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], @@ -174,7 +174,7 @@ describe('UserQuestionService', () => { ])('preserves %s from the provider', async (_label, rejection) => { const ctx = new Context() await ctx.plugin(UserQuestionService) - ctx.userQuestions.registerProvider({ ask: vi.fn().mockRejectedValue(rejection) }) + registerAnswerer(ctx, { ask: vi.fn().mockRejectedValue(rejection) }) await expect(ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], From 7ddccac0d45c8c53b508a39f113744e18f5d6bc5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:41:28 +0800 Subject: [PATCH 202/248] fix(client): remove unused workspace dev dependencies --- packages/client/ui-conversation/package.json | 1 - packages/client/ui-sidebar/package.json | 1 - pnpm-lock.yaml | 12 ++++++------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 876a3dbd9d..10f8621789 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -97,7 +97,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 50ccb7ed46..b345fb6875 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -69,7 +69,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9311ceec08..3672dc9f13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2234,6 +2234,9 @@ importers: packages/client/ui-conversation: dependencies: + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -2286,9 +2289,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../ui-workspace '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -3266,6 +3266,9 @@ importers: packages/client/ui-sidebar: dependencies: + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace clsx: specifier: ^2.0.0 version: 2.1.1 @@ -3297,9 +3300,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants From 956a72ffe05a32914ea835f832886df27bea2c07 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:19:10 +0800 Subject: [PATCH 203/248] fix(client): restore injected workspace dependencies --- knip.json | 10 ++++++++++ packages/client/ui-conversation/package.json | 1 + packages/client/ui-sidebar/package.json | 1 + pnpm-lock.yaml | 12 ++++++------ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/knip.json b/knip.json index 9e0ad83da8..b457dd54f3 100644 --- a/knip.json +++ b/knip.json @@ -131,6 +131,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-conversation": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-workspace" + ] + }, + "packages/client/ui-sidebar": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-workspace" + ] + }, "packages/client/ui-subagent": { "ignoreDependencies": [ "@deepseek-ai/dsh-client-ui-input-trigger" diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 10f8621789..876a3dbd9d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -97,6 +97,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index b345fb6875..50ccb7ed46 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3672dc9f13..9311ceec08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2234,9 +2234,6 @@ importers: packages/client/ui-conversation: dependencies: - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../ui-workspace '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -2289,6 +2286,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -3266,9 +3266,6 @@ importers: packages/client/ui-sidebar: dependencies: - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../ui-workspace clsx: specifier: ^2.0.0 version: 2.1.1 @@ -3300,6 +3297,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants From a40f30a4a24aa25cf5ef3c9067d9f44c596747ce Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:19:22 +0800 Subject: [PATCH 204/248] fix(client): preserve scoped UI lifecycles --- .../client/ui-approval/src/client/index.ts | 2 +- .../ui-conversation/src/client/apply.ts | 15 +++-- .../tests/apply-wiring.client.spec.tsx | 28 ++++++-- .../client/ui-renderer/src/client/registry.ts | 66 ++++++++++++------- .../ui-renderer/tests/registry.client.spec.ts | 35 ++++++++++ .../client/ui-session/src/client/index.ts | 4 +- .../tests/ui-session.client.spec.ts | 15 +++++ .../ui-user-questions/src/client/index.ts | 3 +- 8 files changed, 130 insertions(+), 38 deletions(-) diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts index e94947793c..04efe273c6 100644 --- a/packages/client/ui-approval/src/client/index.ts +++ b/packages/client/ui-approval/src/client/index.ts @@ -11,12 +11,12 @@ import { ApprovalPanel } from './ApprovalPanel.tsx' import { PendingApproval } from './contract/slots.ts' import { en, zh } from './locales.ts' -export { PendingApproval } from './contract/slots.ts' export type { ApprovalComposerProps, ApprovalDecision, ApprovalDetailOwnerProps, ApprovalPresentationRequest, + PendingApproval, } from './contract/slots.ts' export type { ApprovalKey } from './locales.ts' diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index e019ec995b..b154e73f4a 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -169,7 +169,7 @@ export function apply(ctx: Context): void { }, }) - slots.register({ + const registerConversationRoot = () => slots.register({ name: 'conversation', locale: NS, children: { @@ -212,7 +212,7 @@ export function apply(ctx: Context): void { }), }, ConversationRoot) - slots.register({ + const registerConversationSession = () => slots.register({ name: 'conversation.session', children: { 'conversation.view': { kind: 'list', scope: 'session' }, @@ -224,7 +224,7 @@ export function apply(ctx: Context): void { }), }, ConversationSession) - slots.register({ + const registerConversationHeader = () => slots.register({ name: 'conversation.session.header', locale: NS, children: { @@ -239,7 +239,7 @@ export function apply(ctx: Context): void { }), }, ConversationSessionHeader) - slots.register({ + const registerComposerBar = () => slots.register({ name: 'conversation.composer.bar', locale: NS, children: { @@ -323,6 +323,13 @@ export function apply(ctx: Context): void { }, }, InputBar) + slots.inject('conversation', function* () { + yield registerConversationRoot() + yield registerConversationSession() + yield registerConversationHeader() + yield registerComposerBar() + }) + ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks }) ctx.plugin(todoDockEntry) ctx.plugin(queueDockEntry) diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index 168d0e6230..eccf4b87a0 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -12,17 +12,19 @@ usePinnedBrowserLanguages('zh-CN') const SID = 'session-1' as SessionId -async function bench() { +async function bench(options: { declareConversation?: boolean } = {}) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) - await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'settings.general.item': { kind: 'list', scope: 'root' }, - }, (_props: { renderSlot?: unknown }) => null) + if (options.declareConversation !== false) { + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, (_props: { renderSlot?: unknown }) => null) + } const feature = await runtime.mount({ inject: [...inject], apply }) return { runtime, feature } } @@ -35,6 +37,22 @@ function entry( } describe('target-neutral Conversation apply wiring', () => { + it('waits for the layout-owned conversation declaration before registering its subtree', async () => { + const b = await bench({ declareConversation: false }) + expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + + await b.runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, (_props: { renderSlot?: unknown }) => null) + + expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1) + expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1) + expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1) + await b.runtime.dispose() + }) + it('provides both action and assembly services without installing Chat', async () => { const b = await bench() expect(b.runtime.ctx.get('conversation')).toBeDefined() diff --git a/packages/client/ui-renderer/src/client/registry.ts b/packages/client/ui-renderer/src/client/registry.ts index 0725163e57..728cbdcb21 100644 --- a/packages/client/ui-renderer/src/client/registry.ts +++ b/packages/client/ui-renderer/src/client/registry.ts @@ -64,8 +64,6 @@ interface StoreAxisRecord { refs: number /** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */ instances: Map - /** Scope-lifetime registrations for Session instances. */ - lifetimes: Map void> } /** Type-erased options view the implementation works with (the typed overloads proved the shares). */ @@ -98,6 +96,8 @@ export class SlotRegistry extends Service { private readonly _core = new SlotCore() /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */ private readonly _stores = new Map() + /** Latest live Context generation for each scoped store key. */ + private readonly _storeScopeOwners = new Map() private _renderer: SlotRenderer | undefined private _locale: LocaleFace | undefined private _host: SlotRendererHost | undefined @@ -314,6 +314,26 @@ export class SlotRegistry extends Service { }, `slots.installScope(${JSON.stringify(scope)})`) } + /** + * Bind all scoped Store handles to one owner Context lifetime. The cleanup + * materializes an otherwise-unused handle before clearing it, because a + * previous application run may have persisted state for a Slot that this + * scope never rendered. Rebinding the same key transfers cleanup ownership + * to the newest Context generation. + * + * @param binding - materialized scope identity and its owning Context. + */ + bindStoreScope(binding: Pick): void { + const current = this._storeScopeOwners.get(binding.key) + if (current === binding.ctx) return + this._storeScopeOwners.set(binding.key, binding.ctx) + binding.ctx.effect(() => () => { + if (this._storeScopeOwners.get(binding.key) !== binding.ctx) return + this._storeScopeOwners.delete(binding.key) + this.clearStoreScope(binding.key) + }, `slots: store scope ${binding.key}`) + } + /** * The single ctx-level render entry: the shell renders 'root'; every other * key renders inside components through the props renderSlot face. All @@ -510,42 +530,39 @@ export class SlotRegistry extends Service { ): StoreInstanceLike { const record = this._stores.get(handle) if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)') - const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : scopeBinding?.key - if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`) + let key: string + if (record.scope === 'root') { + key = ROOT_INSTANCE_KEY + } else { + if (scopeBinding === undefined) throw new Error(`${record.scope} store resolution requires a session id`) + key = scopeBinding.key + this.bindStoreScope(scopeBinding) + } let instance = record.instances.get(key) if (instance === undefined) { // Session instances get the scope key (the engine suffixes the persist // key per session); root instances stay keyless. instance = record.scope === 'root' ? handle.create() : handle.create(key) record.instances.set(key, instance) - if (record.scope !== 'root') { - const scopeCtx = scopeBinding?.ctx - if (scopeCtx === undefined) { - record.instances.delete(key) - throw new Error(`${record.scope} store resolution requires a scope lifetime`) - } - const owned = instance - const dispose = scopeCtx.effect( - () => () => { - if (this._stores.get(handle) !== record || record.instances.get(key) !== owned) return - owned.clearPersisted() - record.instances.delete(key) - record.lifetimes.delete(key) - }, - `slots: store scope ${key}`, - ) - const release = (): void => { void dispose() } - record.lifetimes.set(key, release) - } } return instance } + /** Clear every live non-root Store handle for one dead scope key. */ + private clearStoreScope(key: string): void { + for (const [handle, record] of this._stores) { + if (record.scope === 'root') continue + const instance = record.instances.get(key) ?? handle.create(key) + instance.clearPersisted() + record.instances.delete(key) + } + } + /** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */ private _acquire(handle: EngineStoreHandle, scope: SlotScope): void { const record = this._stores.get(handle) if (record === undefined) { - this._stores.set(handle, { scope, refs: 1, instances: new Map(), lifetimes: new Map() }) + this._stores.set(handle, { scope, refs: 1, instances: new Map() }) return } record.refs += 1 @@ -561,7 +578,6 @@ export class SlotRegistry extends Service { record.refs -= 1 if (record.refs !== 0) return this._stores.delete(handle) - for (const release of record.lifetimes.values()) release() } } diff --git a/packages/client/ui-renderer/tests/registry.client.spec.ts b/packages/client/ui-renderer/tests/registry.client.spec.ts index 3cddec3dec..627737539b 100644 --- a/packages/client/ui-renderer/tests/registry.client.spec.ts +++ b/packages/client/ui-renderer/tests/registry.client.spec.ts @@ -621,6 +621,41 @@ describe('store instance axis', () => { await replacement.fiber.dispose() }) + it('clears persisted state for scoped stores that were never materialized', async () => { + const { bench } = await storeBench() + const root = fakeHandle() + const scoped = fakeHandle() + bench.erased.register({ name: 't.host', store: root.handle }, C) + bench.erased.register({ name: 't.panel', store: scoped.handle }, C) + const scope = scopedBinding(bench.ctx, 's1') + + bench.svc.bindStoreScope(scope.binding) + await scope.fiber.dispose() + + expect(root.handle.create).not.toHaveBeenCalled() + expect(scoped.handle.create).toHaveBeenCalledOnce() + expect(scoped.handle.create).toHaveBeenCalledWith('s1') + expect(scoped.created[0]?.clearPersisted).toHaveBeenCalledOnce() + }) + + it('leaves scoped Store cleanup with the newest Context generation', async () => { + const { bench } = await storeBench() + const { handle, created } = fakeHandle() + bench.erased.register({ name: 't.panel', store: handle }, C) + const first = scopedBinding(bench.ctx, 's1') + const replacement = scopedBinding(bench.ctx, 's1') + + bench.svc.bindStoreScope(first.binding) + bench.svc.bindStoreScope(first.binding) + bench.svc.bindStoreScope(replacement.binding) + await first.fiber.dispose() + expect(handle.create).not.toHaveBeenCalled() + + await replacement.fiber.dispose() + expect(handle.create).toHaveBeenCalledOnce() + expect(created[0]?.clearPersisted).toHaveBeenCalledOnce() + }) + it('clears session-maybe state through binding disposal and creates a fresh instance on reuse', async () => { const { bench, host } = await storeBench() bench.svc.installScope('session', { diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 36676d1403..0f0c521b78 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -392,13 +392,15 @@ export class UiSession extends Service { copyDeclared('keyed hook', keyedHooks, descriptor.keyedHooks, contribution.keyedHooks, finalProps) copyDeclared('prop', props, descriptor.props, contribution.props, finalProps) } - return { + const value: ScopedStandardSourceBinding = { key: binding.sessionId, ctx: binding.ctx, hooks, keyedHooks, props, } + this.ctx.slots.bindStoreScope(value) + return value } private materializeAbsent(): StandardSourceBinding { diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 4045e4abb9..5b9ebe6f3d 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -138,6 +138,7 @@ function createSessionsBench(_ctx: Context): SessionsBench { } function createUiSession(ctx: Context, bench: SessionsBench): UiSession { + ctx.provide('slots', { bindStoreScope: vi.fn() } as never) return new UiSession(ctx, bench.sessions) } @@ -146,6 +147,20 @@ afterEach(() => { }) describe('UiSession bindings', () => { + it('binds each materialized Session to renderer-owned Store cleanup', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const bindStoreScope = vi.fn() + ctx.provide('slots', { bindStoreScope } as never) + const service = new UiSession(ctx, bench.sessions) + const binding = bench.binding(sessionId('s1')) + + const materialized = service.adapter.resolve(binding.sessionId) + + expect(bindStoreScope).toHaveBeenCalledOnce() + expect(bindStoreScope).toHaveBeenCalledWith(materialized) + }) + it('materializes built-in sources, caches a binding, and publishes selection and release', async () => { const ctx = new Context() const bench = createSessionsBench(ctx) diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 2d1aabb92e..6efc7fa956 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -25,9 +25,8 @@ import { PendingQuestion } from './contract/slots.ts' import { QuestionComposer } from './QuestionComposer.tsx' import { en, zh, type QuestionKey } from './locales.ts' -export { PendingQuestion } from './contract/slots.ts' export type { - PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait, + PendingQuestion, PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait, } from './contract/slots.ts' export type { QuestionKey } from './locales.ts' From 689644463d70252cdf43353853a6e16de2bc04cd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:27:34 +0800 Subject: [PATCH 205/248] fix(client): keep conversation updates incremental --- .../src/client/contract/events.ts | 88 ++++++++++++++++--- .../tests/client-contract.client.spec.ts | 21 +++++ .../src/client/conversation/assembly.ts | 13 ++- .../conversation-registry.client.spec.ts | 10 ++- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/packages/api/session-controller/src/client/contract/events.ts b/packages/api/session-controller/src/client/contract/events.ts index f71c4be0b0..2f8bc48f82 100644 --- a/packages/api/session-controller/src/client/contract/events.ts +++ b/packages/api/session-controller/src/client/contract/events.ts @@ -2,6 +2,66 @@ import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionEventEntry } from '../../types.ts' +interface EventWindowLeaf { + readonly kind: 'leaf' + readonly entries: readonly SessionEventEntry[] + readonly length: number +} + +interface EventWindowConcat { + readonly kind: 'concat' + readonly left: EventWindowNode + readonly right: EventWindowNode + readonly length: number +} + +type EventWindowNode = EventWindowLeaf | EventWindowConcat + +function leaf(entries: readonly SessionEventEntry[]): EventWindowLeaf { + return { kind: 'leaf', entries, length: entries.length } +} + +function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConcat { + return { kind: 'concat', left, right, length: left.length + right.length } +} + +function materialize(node: EventWindowNode): readonly SessionEventEntry[] { + if (node.kind === 'leaf') return node.entries + const entries = new Array(node.length) + const pending: EventWindowNode[] = [node] + let index = 0 + while (pending.length > 0) { + const current = pending.pop() as EventWindowNode + if (current.kind === 'concat') { + pending.push(current.right, current.left) + continue + } + for (const entry of current.entries) { + entries[index] = entry + index += 1 + } + } + return entries +} + +function windowSnapshot( + node: EventWindowNode, + hasMore: boolean, + revision: number, + change: SessionEventChange, +): SessionEventWindow { + let entries: readonly SessionEventEntry[] | undefined + return { + get entries() { + entries ??= materialize(node) + return entries + }, + hasMore, + revision, + change, + } +} + /** Exact delta that produced the latest event-window revision. */ export type SessionEventChange = | { readonly kind: 'replace'; readonly entries: readonly SessionEventEntry[] } @@ -22,12 +82,13 @@ export type SessionEventSource = ObservableSnapshot /** Session-owned event feed; every accepted window mutation publishes synchronously. */ export class MutableSessionEventSource implements SessionEventSource { private readonly listeners = new Set<() => void>() - private snapshot: SessionEventWindow = { - entries: [], - hasMore: false, - revision: 0, - change: { kind: 'replace', entries: [] }, - } + private window: EventWindowNode = leaf([]) + private snapshot: SessionEventWindow = windowSnapshot( + this.window, + false, + 0, + { kind: 'replace', entries: [] }, + ) /** @returns the cached event-window snapshot. */ getSnapshot(): SessionEventWindow { return this.snapshot } @@ -48,7 +109,8 @@ export class MutableSessionEventSource implements SessionEventSource { * @param hasMore - whether older history remains. */ replace(entries: readonly SessionEventEntry[], hasMore: boolean): void { - this.publish(entries, hasMore, { kind: 'replace', entries }) + this.window = leaf(entries) + this.publish(hasMore, { kind: 'replace', entries }) } /** @@ -57,7 +119,8 @@ export class MutableSessionEventSource implements SessionEventSource { * @param hasMore - whether still older history remains. */ prepend(entries: readonly SessionEventEntry[], hasMore: boolean): void { - this.publish([...entries, ...this.snapshot.entries], hasMore, { kind: 'prepend', entries }) + this.window = concat(leaf(entries), this.window) + this.publish(hasMore, { kind: 'prepend', entries }) } /** @@ -65,18 +128,19 @@ export class MutableSessionEventSource implements SessionEventSource { * @param entry - live tail entry. */ append(entry: SessionEventEntry): void { - this.publish([...this.snapshot.entries, entry], this.snapshot.hasMore, { + const entries = [entry] + this.window = concat(this.window, leaf(entries)) + this.publish(this.snapshot.hasMore, { kind: 'append', - entries: [entry], + entries, }) } private publish( - entries: readonly SessionEventEntry[], hasMore: boolean, change: SessionEventChange, ): void { - this.snapshot = { entries, hasMore, revision: this.snapshot.revision + 1, change } + this.snapshot = windowSnapshot(this.window, hasMore, this.snapshot.revision + 1, change) notifySubscribers(this.listeners, '[session-controller] event feed') } } diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index 18919e66e9..9b3a7fa015 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -54,6 +54,27 @@ describe('Client Session contracts', () => { expect(listener).toHaveBeenCalledTimes(3) }) + it('does not traverse the complete event window while appending', () => { + const feed = new MutableSessionEventSource() + const first = entry(1) + const base = [first] + const iterate = vi.fn(Array.prototype[Symbol.iterator].bind(base)) + Object.defineProperty(base, Symbol.iterator, { value: iterate }) + feed.replace(base, false) + iterate.mockClear() + + const before = feed.getSnapshot() + const live = entry(2) + feed.append(live) + const after = feed.getSnapshot() + + expect(iterate).not.toHaveBeenCalled() + expect(before.entries).toEqual([first]) + expect(after.entries).toEqual([first, live]) + expect(after.entries).toBe(after.entries) + expect(iterate).toHaveBeenCalledOnce() + }) + it('folds Error and non-Error carrier rejections into Client failures', () => { expect(transportResult(new Error('transport unavailable'))).toEqual({ ok: false, diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 2a996d6296..cf0c4380b7 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -159,9 +159,18 @@ export class UiConversation extends Service { const rebuild = (): void => { for (const record of this.bindings.values()) record.binding.rebuild() } + let rebuildQueued = false + const scheduleRebuild = (): void => { + if (rebuildQueued) return + rebuildQueued = true + queueMicrotask(() => { + rebuildQueued = false + rebuild() + }) + } ctx.effect(() => { - const disposeEvents = this.events.subscribe(rebuild) - const disposeViews = this.views.subscribe(rebuild) + const disposeEvents = this.events.subscribe(scheduleRebuild) + const disposeViews = this.views.subscribe(scheduleRebuild) return () => { disposeViews() disposeEvents() diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 06e76ce8c2..3df509dcdf 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -226,15 +226,21 @@ describe('Conversation registries', () => { expect(views.entries()).toEqual([]) }) - it('rebuilds every resident Conversation binding after each registry change', async () => { + it('coalesces one turn of registry changes into one rebuild per resident Conversation', async () => { const { uiConversation, binding, events, views } = await bootRegistries() uiConversation.binding(binding) const rebuild = vi.spyOn(ConversationNodeAssembler.prototype, 'rebuildRegistry') events.register(eventDefinition('message')) + views.register(viewDefinition('chat')) + events.register(eventDefinition('tool')) + expect(rebuild).not.toHaveBeenCalled() + + await Promise.resolve() expect(rebuild).toHaveBeenCalledOnce() - views.register(viewDefinition('chat')) + views.register(viewDefinition('trajectory')) + await Promise.resolve() expect(rebuild).toHaveBeenCalledTimes(2) rebuild.mockRestore() }) From 61ee17697330bf893f8c29b4596f811de5057182 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:47:40 +0800 Subject: [PATCH 206/248] docs(client): align split ownership contracts --- ...client-session-conversation-ownership.i18n.yaml | 4 ++-- ...-08-20-client-session-conversation-ownership.md | 6 +++--- ...-20-client-session-conversation-ownership.zh.md | 6 +++--- packages/client/AGENTS.md | 6 +++--- .../interaction/user-questions/README.i18n.yaml | 4 ++-- packages/interaction/user-questions/README.md | 14 ++++++-------- packages/interaction/user-questions/README.zh.md | 14 ++++++-------- 7 files changed, 25 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml index 43a002ec74..f616ad829c 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.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 .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md -2026-08-20-client-session-conversation-ownership.md: e7ab737c13721c41d9244c7830c74cf56ea33f54 -2026-08-20-client-session-conversation-ownership.zh.md: deeca51740cf07e15192744c027e7bc09369bdfa +2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa +2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md index e7ab737c13..8e5521ff19 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -152,7 +152,7 @@ Ordinary UI components do not read `SessionEventSource` directly. `ui-session` d `SessionEventSource` exposes a materialized event window, not a transport. -The window carries ordered `entries`, `hasMore`, a monotonic `revision`, and a `replace | prepend | append` change description. +The window carries ordered `entries`, `hasMore`, a monotonic `revision`, and a `replace | prepend | append` change description. Append links an immutable segment in constant time; a consumer that needs the complete `entries` array materializes and caches it for that snapshot. Initial open, reconnect, gap repair, and updates whose continuity cannot be proven publish `replace`; history pagination publishes `prepend`; a continuous live event publishes `append`. The Conversation core selects incremental update or complete rebuild from the revision and change. @@ -214,7 +214,7 @@ Session identity comes from the scope binding and standard `sessionId` prop. The Business packages extend `SessionPendingInteractionMap` through declaration merging. Every pending object carries at least a stable `key`, domain `kind`, and `sessionId`; `ui-session` does not import concrete Approval or Question types. -A business plugin calls `registerPendingInteraction(precedence)` in `apply()` to create a stable registration for its pending domain. The returned per-request publication function publishes one exact object and returns an idempotent disposer for that object. +A business plugin calls `registerPendingInteraction(precedence)` in `apply()` to create a stable registration for its pending domain. The returned per-request publication function publishes one exact object together with its waterfall-delegation callback and returns an idempotent disposer for that object. Plugin teardown removes all published objects before invoking and awaiting their delegation callbacks, so active Host requests cannot remain suspended after their Client answerer unloads. Concurrent objects with the same key are rejected; replacement requests use a new key. One Session may hold multiple domains or requests at once. @@ -266,7 +266,7 @@ Definition or View roster changes rebuild only the Conversation binding; they do `UiConversation.events` is the sole registry for event Definitions, and `UiConversation.views` is the sole registry for target snapshot builders. -The registries reject duplicate keys, preserve registration order, and return idempotent disposers. Existing Conversation bindings rebuild from their current event windows when a roster changes. +The registries reject duplicate keys, preserve registration order, and return idempotent disposers. Existing Conversation bindings rebuild from their current event windows when a roster changes; changes in one synchronous registration turn are coalesced into one microtask rebuild. A target package extends snapshot and location-data maps through declaration merging, then registers its Definitions, builder, and View. Registrations follow Cordis effect disposal. diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index deeca51740..a007a42b3d 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -152,7 +152,7 @@ Session Controller 对外提供三个互不替代的读取面: `SessionEventSource` 暴露已经物化的事件窗口,而不是 transport。 -窗口携带有序 `entries`、`hasMore`、单调 `revision`,以及 `replace | prepend | append` 变更描述。 +窗口携带有序 `entries`、`hasMore`、单调 `revision`,以及 `replace | prepend | append` 变更描述。Append 以常数时间连接不可变片段;需要完整 `entries` 数组的消费者才为该 snapshot 物化并缓存数组。 首次打开、重连、gap repair 和无法证明连续性的更新发布 `replace`;历史分页发布 `prepend`;连续 live event 发布 `append`。Conversation core 依据 revision 与 change 选择增量更新或完整 rebuild。 @@ -214,7 +214,7 @@ Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provid `SessionPendingInteractionMap` 由业务 package declaration merge 扩展。每个 pending object 至少携带稳定 `key`、领域 `kind` 和 `sessionId`;`ui-session` 不 import Approval 或 Question 的具体类型。 -业务 plugin 在 `apply()` 中调用 `registerPendingInteraction(precedence)`,为自己的 pending domain 建立稳定注册。该调用返回逐请求 publication function;publication function 发布一个精确对象,并返回移除该对象的幂等 disposer。 +业务 plugin 在 `apply()` 中调用 `registerPendingInteraction(precedence)`,为自己的 pending domain 建立稳定注册。该调用返回逐请求 publication function;publication function 同时发布精确对象及其 waterfall 委托回调,并返回移除该对象的幂等 disposer。Plugin teardown 会先移除所有已发布对象,再调用并等待其委托回调,避免 Client 回答者卸载后 Host 请求继续悬挂。 相同 key 的并发对象被拒绝,替换请求必须使用新 key。同一 Session 可以同时存在多个领域或多个请求。 @@ -266,7 +266,7 @@ Definition 或 View roster 变化只重建 Conversation binding,不重建 Sess `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。 -Registry 拒绝重复 key,保持注册顺序并返回幂等 disposer。Roster 变化时,现有 Conversation binding 使用当前 event window 重建。 +Registry 拒绝重复 key,保持注册顺序并返回幂等 disposer。Roster 变化时,现有 Conversation binding 使用当前 event window 重建;同一同步注册轮次中的变化会合并为一次 microtask 重建。 Target package 通过 declaration merge 扩展 snapshot 与 location data map,再向 registry 注册自己的 Definition、builder 和 View。注册随 Cordis effect 释放。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 93e3cba07b..9f6f894abb 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -43,7 +43,7 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): -1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. +1. **Data object layer** (React-free): `client/connection` owns transport generations, `api/session-controller/client` owns `ClientSessions` → `SessionManager` → `Session`, `api/workspace-controller/client` owns Workspace state, and `client/store` owns the snapshot-store engine (`defineStore`, `createSnapshotStore`, `shallowEqual`). Store products are bare observable sources with no hook members. 2. **Render machinery** (`ui-renderer`, dynamic plugin): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; production business code carries no ui-renderer value dependency. 3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. @@ -72,9 +72,9 @@ Client business code may statically read `process.env.DSH_CLIENT_*`; every refer ## Shared modules and the module graph -A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot. +A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. -1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, runtime, `ui-primitives`, or `ui-slots` in package manifests. +1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, or `ui-slots` in package manifests. 2. **`dsh.client.external` adds a package-specific request.** Use it only for a non-baseline value import whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. 3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request. 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol. diff --git a/packages/interaction/user-questions/README.i18n.yaml b/packages/interaction/user-questions/README.i18n.yaml index bdcbeee898..a2007fe9f6 100644 --- a/packages/interaction/user-questions/README.i18n.yaml +++ b/packages/interaction/user-questions/README.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 packages/interaction/user-questions/README.md -README.md: 53459c39c75d5d3907b002239c83db5f82e024f5 -README.zh.md: 3a1f016aef6efa0a2167523fd4e1a938fa53eb71 +README.md: 2869fabb2737163bbb4ccde48997d4486169e46d +README.zh.md: cfe72d320cac426f0b770b1d2f78c528cda2df88 diff --git a/packages/interaction/user-questions/README.md b/packages/interaction/user-questions/README.md index 53459c39c7..2869fabb27 100644 --- a/packages/interaction/user-questions/README.md +++ b/packages/interaction/user-questions/README.md @@ -8,8 +8,7 @@ User-interaction Service Definition. It owns `ctx.userQuestions`, the service a ### Public API -- `ctx.userQuestions.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it. -- `ctx.userQuestions.ask(request): Promise` Ask the active provider and wait for the answer. +- `ctx.userQuestions.ask(request): Promise` Dispatch the answerer waterfall and wait for the first accepted answer. ### Key Types @@ -17,12 +16,11 @@ User-interaction Service Definition. It owns `ctx.userQuestions`, the service a - `AskUserQuestionOption` — `{ label, description? }`. - `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. -- `UserQuestionProvider` — UI implementation with `ask(request)`. -- `UserQuestionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`. +- `UserQuestionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. -When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. Agentless programmatic requests retain the existing provider path. +When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. The Web answerer receives only Agent-scoped requests; an agentless programmatic request remains available to unscoped local waterfall listeners and fails with `NO_PROVIDER` when none accepts it. ### Presentation intent @@ -30,11 +28,11 @@ When a request carries an agent, `ask()` authenticates its exact identity throug ## Role -This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this service; the Web host runtime supplies the shipped Service Provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. +This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this service; the Web client contributes an Agent-scoped answerer through Remote Events. The loop stays unchanged: a tool call awaits the waterfall result, and that result resumes the normal agent loop. ## Model Experience -Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-questions provider is registered`, or `Error: `. Waiting for the human adds no tokens. +Indirectly, through `dsh-tool-ask-user`, which retains a successful answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-questions answerer accepted the request`, or `Error: `. Waiting for the human adds no tokens. #### KV Cache effect @@ -42,5 +40,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **One provider per context** — there is no routing or fan-out to multiple UIs; a second registration throws `DUPLICATE_PROVIDER`, and with none registered `ask()` throws `NO_PROVIDER` rather than degrading. +- **Agent-scoped Web answering** — Remote Events route the shipped Web answerer only when the request carries a live Agent scope; agentless callers need an unscoped local waterfall listener. - **The vocabulary is the question-form shape only** — selectable options plus optional custom text; richer interaction shapes (file pickers, diff-preview confirmations) have no seam vocabulary yet. diff --git a/packages/interaction/user-questions/README.zh.md b/packages/interaction/user-questions/README.zh.md index 3a1f016aef..cfe72d320c 100644 --- a/packages/interaction/user-questions/README.zh.md +++ b/packages/interaction/user-questions/README.zh.md @@ -8,8 +8,7 @@ ### 公开 API -- `ctx.userQuestions.registerProvider(provider): () => void` 注册 UI 侧提供方。同一上下文中只能有一个活跃提供方;dispose(资源释放)会将其注销。 -- `ctx.userQuestions.ask(request): Promise` 向活跃提供方提问并等待回答。 +- `ctx.userQuestions.ask(request): Promise` 派发回答者 waterfall,并等待第一个接受请求的回答。 ### 关键类型 @@ -17,12 +16,11 @@ - `AskUserQuestionOption`:`{ label, description? }`。 - `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。 - `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 -- `UserQuestionProvider`:包含 `ask(request)` 的 UI 实现。 -- `UserQuestionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。 +- `UserQuestionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。 对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 -请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;归属于另一个 agent 的存活子级即使持久化记录的委托深度为零也会被拒绝。不含 agent 的程序化请求继续沿用现有提供方路径。 +请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;归属于另一个 agent 的存活子级即使持久化记录的委托深度为零也会被拒绝。Web 回答者只接收带 Agent scope 的请求;不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人接受则以 `NO_PROVIDER` 失败。 ### 呈现意图 @@ -30,11 +28,11 @@ ## 职责 -这是 Service Definition 包。`@deepseek-ai/dsh-tool-ask-user` 等 Consumer 依赖此服务;Web 宿主运行时提供随产品交付的 Service Provider。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 +这是 Service Definition 包。`@deepseek-ai/dsh-tool-ask-user` 等 Consumer 依赖此服务;Web Client 通过 Remote Events 贡献带 Agent scope 的回答者。循环保持不变:工具调用等待 waterfall 结果,该结果随后恢复正常的 agent loop(智能体循环)。 ## 模型体验 -间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: human interaction requires the exact live calling agent when an agent is supplied`、`Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`、`Error: no user-questions provider is registered` 或 `Error: `。等待人类回答不会增加 token。 +间接地,通过 `dsh-tool-ask-user`:它会将成功回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: human interaction requires the exact live calling agent when an agent is supplied`、`Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`、`Error: no user-questions answerer accepted the request` 或 `Error: `。等待人类回答不会增加 token。 #### KV Cache 影响 @@ -42,5 +40,5 @@ ## 已知限制与暂缓事项 -- **每个上下文只能有一个提供方**:不支持路由或扇出到多个 UI;第二次注册会抛出 `DUPLICATE_PROVIDER`,未注册任何提供方时,`ask()` 会抛出 `NO_PROVIDER`,而不会降级。 +- **带 Agent scope 的 Web 回答**:Remote Events 仅在请求带有存活 Agent scope 时路由随产品交付的 Web 回答者;agentless 调用方需要本地未限定 scope 的 waterfall listener。 - **词汇仅包含问题表单形态**:可供选择的选项加可选的自定义文本;更丰富的交互形态(文件选择器、diff 预览确认)尚无 seam 词汇。 From 7402ce3fc771e03ecd164c3c69624df33d326e8b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:47:54 +0800 Subject: [PATCH 207/248] fix(client): settle interactions during plugin teardown --- .../ui-approval/src/client/contract/slots.ts | 16 ++++++++ .../client/ui-approval/src/client/index.ts | 18 ++++++-- .../tests/ui-approval.client.spec.tsx | 32 +++++++++++++-- .../client/ui-session/src/client/index.ts | 40 ++++++++++++++---- .../tests/ui-session.client.spec.ts | 41 +++++++++++++++---- .../src/client/contract/slots.ts | 16 ++++++++ .../ui-user-questions/src/client/index.ts | 18 ++++++-- .../tests/browser-plugin.client.spec.ts | 29 +++++++++++-- 8 files changed, 178 insertions(+), 32 deletions(-) diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 3f8d481767..0c12b119e7 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -72,6 +72,7 @@ export class PendingApproval { readonly #reject: (reason: unknown) => void readonly #signal: AbortSignal | undefined readonly #onAbort: (() => void) | undefined + readonly #delegated = Symbol('pending approval delegated') #settled = false /** @@ -111,6 +112,21 @@ export class PendingApproval { }, 'pending approval settlement failed') } + /** Delegate an unanswered request to the next waterfall listener. */ + delegate(): void { + if (this.#settled) return + this.finish(() => { this.#reject(this.#delegated) }) + } + + /** + * Test whether a rejection requests waterfall delegation. + * @param reason - rejection received from {@link PendingApproval.result}. + * @returns whether {@link PendingApproval.delegate} produced it. + */ + isDelegation(reason: unknown): boolean { + return reason === this.#delegated + } + /** * End an unanswered presentation when its transport, scope, or plugin lifetime ends. * @param reason - rejection exposed to the waiting Remote Event listener. diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts index 04efe273c6..aeb34a433c 100644 --- a/packages/client/ui-approval/src/client/index.ts +++ b/packages/client/ui-approval/src/client/index.ts @@ -4,7 +4,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-session/client' import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ApprovalPanel } from './ApprovalPanel.tsx' @@ -36,7 +36,7 @@ async function answerApproval( owner: ClientContext, request: ClientApprovalRequest, next: ClientApprovalNext, - registerPendingInteraction: (pending: PendingApproval) => () => void, + registerPendingInteraction: PendingInteractionPublisher, ): Promise { const sessionId = ctx.sessions.scopeOf(owner) if (sessionId === undefined) return next() @@ -48,11 +48,21 @@ async function answerApproval( ...(request.reason === undefined ? {} : { reason: request.reason }), ...(request.signal === undefined ? {} : { signal: request.signal }), }) - const remove = registerPendingInteraction(pending) + const completed = Promise.withResolvers() + const remove = registerPendingInteraction(pending, async () => { + pending.delegate() + await completed.promise + }) try { - return await pending.result + try { + return await pending.result + } catch (error) { + if (pending.isDelegation(error)) return await next() + throw error + } } finally { remove() + completed.resolve() } } diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index 9e326f18f0..665ae928a4 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -33,6 +33,7 @@ interface PluginBench { readonly disposeLocale: ReturnType readonly register: ReturnType readonly injectSlot: ReturnType + releasePending(): Promise registration(): { options: { select(props: { pendingInteraction: PendingApproval | undefined }): PendingApproval | null @@ -52,13 +53,14 @@ function setupPlugin(): PluginBench { } | undefined const disposeSlot = vi.fn() const disposeLocale = vi.fn() - let pending: readonly PendingApproval[] = [] + const pending = new Map Promise>() const registerPendingInteraction = vi.fn((_precedence: (value: PendingApproval) => number) => ( value: PendingApproval, + delegate: () => Promise, ) => { _precedence(value) - pending = [...pending, value] - return () => { pending = pending.filter(candidate => candidate !== value) } + pending.set(value, delegate) + return () => { pending.delete(value) } }) const register = vi.fn(( options: NonNullable['options'], @@ -89,12 +91,17 @@ function setupPlugin(): PluginBench { return { ctx, listener, - pending: { getSnapshot: () => pending }, + pending: { getSnapshot: () => [...pending.keys()] }, registerPendingInteraction, disposeSlot, disposeLocale, register, injectSlot, + async releasePending() { + const delegates = [...pending.values()] + pending.clear() + await Promise.allSettled(delegates.map(delegate => delegate())) + }, registration: () => { if (registration === undefined) throw new Error('approval slot was not registered') return registration @@ -129,6 +136,7 @@ describe('PendingApproval', () => { expect(pending.reason).toBe('needs access') expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) expect(() => { pending.abort(new Error('late')) }).not.toThrow() + expect(() => { pending.delegate() }).not.toThrow() await expect(pending.answer('rejected')).rejects.toThrow(/already settled/) }) @@ -258,6 +266,22 @@ describe('approval Remote Event consumer', () => { await scope.fiber.dispose() }) + it('delegates an active request when its interaction domain unloads', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) + const result = bench.listener.call(scope.ctx, { toolName: 'bash' }, next) + expect(bench.pending.getSnapshot()).toHaveLength(1) + + await bench.releasePending() + + await expect(result).resolves.toBe('unavailable') + expect(next).toHaveBeenCalledOnce() + expect(bench.pending.getSnapshot()).toEqual([]) + await scope.fiber.dispose() + }) + it('publishes a scoped request without optional request metadata', async () => { const bench = setupPlugin() const scope = createScope(bench.ctx, id('s1')) diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 0f0c521b78..b720041f4d 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -55,8 +55,19 @@ export type SessionPendingInteractionSnapshot = ReadonlyMap +/** Publish one pending interaction and define how plugin teardown delegates it. */ +export type PendingInteractionPublisher = ( + interaction: T, + delegate: () => Promise, +) => () => void + +interface PendingInteractionEntry { + readonly interaction: T + readonly delegate: () => Promise +} + class PendingInteractionDomain { - private readonly values = new Map() + private readonly values = new Map>() constructor( readonly precedence: (interaction: T) => number, @@ -64,23 +75,30 @@ class PendingInteractionDomain { ) {} valuesSnapshot(): readonly T[] { - return [...this.values.values()] + return [...this.values.values()].map(entry => entry.interaction) } - publish(interaction: T): () => void { + publish(interaction: T, delegate: () => Promise): () => void { if (this.values.has(interaction.key)) { throw new Error(`ui-session: duplicate pending interaction key '${interaction.key}'`) } - this.values.set(interaction.key, interaction) + this.values.set(interaction.key, { interaction, delegate }) this.changed() let active = true return () => { if (!active) return active = false - this.values.delete(interaction.key) + if (!this.values.delete(interaction.key)) return this.changed() } } + + /** Remove every pending value and return the operations that settle their owners. */ + release(): readonly (() => Promise)[] { + const delegates = [...this.values.values()].map(entry => entry.delegate) + this.values.clear() + return delegates + } } declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -278,12 +296,14 @@ export class UiSession extends Service { /** * Register one pending-interaction domain and return its publication function. + * Domain teardown first removes its visible values, then delegates and awaits + * every still-active owner request. * @param precedence - deterministic cross-domain precedence; larger values win. - * @returns a function that publishes one exact interaction until its disposer runs. + * @returns a function that publishes one interaction and its teardown delegation. */ registerPendingInteraction( precedence: (interaction: T) => number, - ): (interaction: T) => () => void { + ): PendingInteractionPublisher { const domain = new PendingInteractionDomain(precedence, () => { this.publishPendingInteractions() }) @@ -291,13 +311,15 @@ export class UiSession extends Service { this.ctx.effect(() => { this.pendingDomains.push(runtimeDomain) this.publishPendingInteractions() - return () => { + return async () => { + const delegates = domain.release() const index = this.pendingDomains.indexOf(runtimeDomain) this.pendingDomains.splice(index, 1) this.publishPendingInteractions() + await Promise.allSettled(delegates.map(delegate => Promise.resolve().then(delegate))) } }, 'uiSession.registerPendingInteraction()') - return interaction => domain.publish(interaction) + return (interaction, delegate) => domain.publish(interaction, delegate) } private rebuildBindings(): void { diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 5b9ebe6f3d..97946f4c1d 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -444,15 +444,16 @@ describe('UiSession pending interactions', () => { const question = { key: 'question:1', kind: 'question', sessionId: id } const plan = { key: 'question:2', kind: 'plan-review', sessionId: id } const background = { key: 'background:1', kind: 'background', sessionId: id } - const removeApproval = registerApproval(approval) + const delegate = (): Promise => Promise.resolve() + const removeApproval = registerApproval(approval, delegate) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval) - const removeDuplicate = registerApproval(duplicate) + const removeDuplicate = registerApproval(duplicate, delegate) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate) - const removeQuestion = registerQuestion(question) + const removeQuestion = registerQuestion(question, delegate) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question) - const removePlan = registerQuestion(plan) + const removePlan = registerQuestion(plan, delegate) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) - const removeBackground = registerBackground(background) + const removeBackground = registerBackground(background, delegate) expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) removeBackground() @@ -477,8 +478,9 @@ describe('UiSession pending interactions', () => { () => 1, ) const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') } - const remove = registerPendingInteraction(interaction) - expect(() => { registerPendingInteraction(interaction) }) + const delegate = () => Promise.resolve() + const remove = registerPendingInteraction(interaction, delegate) + expect(() => { registerPendingInteraction(interaction, delegate) }) .toThrow("ui-session: duplicate pending interaction key 'question:1'") const failure = new Error('pending subscriber failed') @@ -495,6 +497,31 @@ describe('UiSession pending interactions', () => { failure, ) }) + + it('removes active values before awaiting their teardown delegation', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const gate = Promise.withResolvers() + const delegate = vi.fn(() => gate.promise) + const publish = service.registerPendingInteraction(() => 1) + const remove = publish( + { key: 'question:1', kind: 'question', sessionId: sessionId('s1') }, + delegate, + ) + + let disposed = false + const disposal = ctx.fiber.dispose().then(() => { disposed = true }) + await vi.waitFor(() => { expect(delegate).toHaveBeenCalledOnce() }) + expect(service.pendingInteractions.getSnapshot()).toEqual(new Map()) + expect(disposed).toBe(false) + remove() + remove() + + gate.resolve(undefined) + await disposal + expect(disposed).toBe(true) + }) }) describe('ui-session apply', () => { diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index cec6e85fb8..0a65ef79eb 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -107,6 +107,7 @@ export class PendingQuestion { readonly #reject: (reason: unknown) => void readonly #signal: AbortSignal | undefined readonly #onAbort: (() => void) | undefined + readonly #delegated = Symbol('pending question delegated') #settled = false /** @@ -150,6 +151,21 @@ export class PendingQuestion { }, 'pending question settlement failed') } + /** Delegate an unanswered request to the next waterfall listener. */ + delegate(): void { + if (this.#settled) return + this.finish(() => { this.#reject(this.#delegated) }) + } + + /** + * Test whether a rejection requests waterfall delegation. + * @param reason - rejection received from {@link PendingQuestion.result}. + * @returns whether {@link PendingQuestion.delegate} produced it. + */ + isDelegation(reason: unknown): boolean { + return reason === this.#delegated + } + /** Reject the Host waterfall because the user closed the question. */ cancel(): Promise { return settlePendingComposer(() => { diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 6efc7fa956..5344c80ed7 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -16,7 +16,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-session/client' import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -54,16 +54,26 @@ async function answerQuestion( owner: ClientContext, request: ClientQuestionRequest, next: ClientQuestionNext, - registerPendingInteraction: (pending: PendingQuestion) => () => void, + registerPendingInteraction: PendingInteractionPublisher, ): Promise { const sessionId = ctx.sessions.scopeOf(owner) if (sessionId === undefined) return next() const pending = new PendingQuestion(sessionId, request.questions, request.signal) - const remove = registerPendingInteraction(pending) + const completed = Promise.withResolvers() + const remove = registerPendingInteraction(pending, async () => { + pending.delegate() + await completed.promise + }) try { - return await pending.result + try { + return await pending.result + } catch (error) { + if (pending.isDelegation(error)) return await next() + throw error + } } finally { remove() + completed.resolve() } } diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 866ac883e0..881e6fa193 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -49,13 +49,14 @@ async function bench(declare = true) { candidate as Context & { [SESSION_SCOPE]?: SessionId } )[SESSION_SCOPE]) ctx.provide('sessions', { scopeOf } as never) - let pending: readonly PendingQuestion[] = [] + const pending = new Map Promise>() const registerPendingInteraction = vi.fn((_precedence: (value: PendingQuestion) => number) => ( value: PendingQuestion, + delegate: () => Promise, ) => { _precedence(value) - pending = [...pending, value] - return () => { pending = pending.filter(candidate => candidate !== value) } + pending.set(value, delegate) + return () => { pending.delete(value) } }) ctx.provide('uiSession', { registerPendingInteraction } as never) let listener: QuestionListener | undefined @@ -81,11 +82,16 @@ async function bench(declare = true) { locale, agent, scopeOf, - pending: { getSnapshot: () => pending }, + pending: { getSnapshot: () => [...pending.keys()] }, registerPendingInteraction, on, fiber, invoke, + async releasePending() { + const delegates = [...pending.values()] + pending.clear() + await Promise.allSettled(delegates.map(delegate => delegate())) + }, } } @@ -174,6 +180,20 @@ describe('apply', () => { expect(b.slots.entries('conversation.composer')).toHaveLength(1) }) + it('delegates an active request when its interaction domain unloads', async () => { + const b = await bench() + const next = vi.fn(async () => ANSWER) + const result = b.invoke(b.agent, { questions: QUESTIONS }, next) + await Promise.resolve() + expect(b.pending.getSnapshot()).toHaveLength(1) + + await b.releasePending() + + await expect(result).resolves.toBe(ANSWER) + expect(next).toHaveBeenCalledOnce() + expect(b.pending.getSnapshot()).toEqual([]) + }) + it('removes the stable composer with the plugin lifetime', async () => { const b = await bench() expect(b.slots.entries('conversation.composer')).toHaveLength(1) @@ -216,6 +236,7 @@ describe('PendingQuestion', () => { await pending.answer(ANSWER) await expect(pending.result).resolves.toBe(ANSWER) pending.abort(new Error('late disposal')) + pending.delegate() }) it('rejects an unanswered request with its caller-owned lifecycle reason', async () => { From e4fb885f3e71e1d51a4c6270c051af81029efed6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:55:53 +0800 Subject: [PATCH 208/248] chore(client): mark mirrored interaction lifecycle --- packages/client/ui-approval/src/client/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts index aeb34a433c..cc044f4fc1 100644 --- a/packages/client/ui-approval/src/client/index.ts +++ b/packages/client/ui-approval/src/client/index.ts @@ -30,6 +30,7 @@ type ClientApprovalRequest = Parameters[0] type ClientApprovalNext = Parameters[1] type ClientApprovalOutcome = Awaited> +/* jscpd:ignore-start -- Approval and Question intentionally mirror one Remote waterfall lifecycle. */ /** Present one request until the user answers or its lifetime ends. */ async function answerApproval( ctx: ClientContext, @@ -65,6 +66,7 @@ async function answerApproval( completed.resolve() } } +/* jscpd:ignore-end */ /** * Install approval copy and the scoped waterfall consumer. From 3c1c6a89b15ef6b203c727018cf19644fe23b5cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:53:55 +0800 Subject: [PATCH 209/248] test(python): gate installed runtime wheels across release targets (#2953) * test(python): exercise installed wheels as black boxes Add an installed-wheel mode that refuses source/editable imports, repository working directories, mismatched SDK/runtime versions, unpinned runtime dependencies, and executables outside the installed runtime distribution. The mode resolves the wheel-owned executable itself, so callers cannot accidentally prove an explicit checkout artifact. Add a real-API scenario that drives two tool-using turns through the public synchronous SDK, verifies the file bytes outside the agent, checks completed turn/tool events and persistence, and projects provider failures without retaining credential-bearing error text. The existing deterministic scenario set remains the keyless behavior oracle. Refs #2952. * ci(python): require installed-wheel checks on every release target Move the complete deterministic runtime scenarios behind construction and clean installation of the SDK and matching runtime wheels. Each native leg runs outside the checkout with source-resolution environment variables removed; Linux manylinux smokes assert the same installed provenance. Expand the required pull-request call from Linux x64 to Linux x64, Linux arm64, and macOS arm64. Trusted heads receive only DEEPSEEK_API_KEY_EXTERNAL for a fail-loud live two-turn smoke on each carrier, while fork and Dependabot heads retain the full keyless path without exposing secrets. Pin the reusable secret declaration, matrix call, aggregate dependency, untrusted-head condition, and live/keyless commands in the workflow contract test. Refs #2952. * docs(testing): make installed wheels the Python CI authority Record the clean-wheel provenance boundary, complete keyless scenario set, trusted real-API contract, secret handling, and three-target required topology in a new implemented testing decision. Update the SEA distribution and portable-CI authorities plus the Python contributor reference to describe the same current state. Archive the fully superseded Linux-x64-only decision after consolidating its rationale and alternatives into the new owner. Preserve its bilingual triplet as a sealed historical snapshot and redirect every active current-state reference. Refs #2952. --- .agents/notes/archived/manifest.json | 5 +- ...d-python-runtime-pull-request-ci.i18n.yaml | 4 +- ...required-python-runtime-pull-request-ci.md | 1 + ...uired-python-runtime-pull-request-ci.zh.md | 1 + ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- ...ortable-required-pull-request-ci.i18n.yaml | 4 +- ...07-23-portable-required-pull-request-ci.md | 2 +- ...23-portable-required-pull-request-ci.zh.md | 2 +- ...talled-python-wheel-black-box-ci.i18n.yaml | 6 + ...-23-installed-python-wheel-black-box-ci.md | 51 +++ ...-installed-python-wheel-black-box-ci.zh.md | 51 +++ .../workflows/build-exe-for-python-sdk.yml | 70 +++- .github/workflows/ci.yml | 13 +- python/development.i18n.yaml | 4 +- python/development.md | 6 +- python/development.zh.md | 6 +- scripts/ci-workflow.spec.ts | 40 ++- scripts/smoke-python-runtime.py | 308 +++++++++++++++++- .../restart/requests.json | 58 ++++ .../python-sdk-single-exe/restart/result.json | 94 ++++++ .../restart/session.1.jsonl | 18 + .../restart/session.2.jsonl | 18 + 24 files changed, 727 insertions(+), 47 deletions(-) rename .agents/notes/{implemented => archived}/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml (66%) rename .agents/notes/{implemented => archived}/testing/2026-08-12-required-python-runtime-pull-request-ci.md (99%) rename .agents/notes/{implemented => archived}/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md (99%) create mode 100644 .agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md create mode 100644 .agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md create mode 100644 scripts/snapshots/python-sdk-single-exe/restart/requests.json create mode 100644 scripts/snapshots/python-sdk-single-exe/restart/result.json create mode 100644 scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl create mode 100644 scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 05fb2f3712..aa76eab57c 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -480,6 +480,9 @@ "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa", "testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml": "sha256:dd45cddb591b892739b75b0c180bde7f14008f4769227b863571475be295e1e0", "testing/2026-07-26-execa-for-test-subprocess-plumbing.md": "sha256:1f45a69d0a7367ec5afbf112a77b355339b35270af8ff52696bee879cdf770d3", - "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b" + "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b", + "testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml": "sha256:741e7e58e5e8a9c82d901c4a16a70cea9bd256eac0e94179b5a24a231bb9fe1f", + "testing/2026-08-12-required-python-runtime-pull-request-ci.md": "sha256:1f1273d7a550667533e29c76efd148aebf57581a91729c877b44a5e43a52d9ad", + "testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md": "sha256:6b9bf126c6b83d9b21e135d38df677c0d5623168b4353c6ddb706f76762c2193" } } diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml similarity index 66% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml index 773ac10950..d6f71ad4c3 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.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 .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md -2026-08-12-required-python-runtime-pull-request-ci.md: 61b1e832be6d29eafe5cb304d2bca3f0a59e3d84 -2026-08-12-required-python-runtime-pull-request-ci.zh.md: 1702af710837c45094711cf52e88bd54d71f7171 +2026-08-12-required-python-runtime-pull-request-ci.md: e7da767f22634bd50bc4fd38b1de34677c4124e7 +2026-08-12-required-python-runtime-pull-request-ci.zh.md: 702125b0da864eb35f1fe870748cc0e314b01a39 diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md similarity index 99% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md index 61b1e832be..e7da767f22 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md @@ -1,6 +1,7 @@ # Agent Note: Required Python runtime pull-request validation Status: implemented +Archived: 2026-08-23 English | [中文](2026-08-12-required-python-runtime-pull-request-ci.zh.md) diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md similarity index 99% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md index 1702af7108..702125b0da 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md @@ -1,6 +1,7 @@ # Agent Note: 必需的 Python 运行时拉取请求验证 Status: implemented +Archived: 2026-08-23 [English](2026-08-12-required-python-runtime-pull-request-ci.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 68a1a8bbd0..ab17030d64 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 24d95be871b2cb06e07706e6d5e0a628e30ef1bd -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: eb9ba37c28e89a894ae7f5132592aa98fa56b977 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 2a39409c7db2bf1de75843e3642ef27051ccfb17 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0f4bb7cf3e1914c008ae23590a3a26fdb87f0842 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 24d95be871..2a39409c7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all three targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development @@ -64,7 +64,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index eb9ba37c28..0f4bb7cf3e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部三个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 @@ -64,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 0bfe6c03b5..3f61c0bb86 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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 .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 6520a16fb4aa5f03e364a17392c87fe0df459ea1 -2026-07-23-portable-required-pull-request-ci.zh.md: cf57ae0d409fca750b5e33e533b8650c747f1abf +2026-07-23-portable-required-pull-request-ci.md: 00a58136b8e6d5a2f282bede9876d2f96e6ddf52 +2026-07-23-portable-required-pull-request-ci.zh.md: e367408850efe97457f4921150d39a1cfe34aed3 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 6520a16fb4..00a58136b8 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) (pull-request-only) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, the Python SDK unit suite, and the [release-shaped Linux x64 Python runtime validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), while the serial references (in `ci-master.yml`) remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) (pull-request-only) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard-hosted jobs retain Node 22.19, Node 26, the Python SDK unit suite, and [installed-wheel Python runtime validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on every published native target, while the serial references (in `ci-master.yml`) remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The three Linux primary jobs, Node compatibility, Python SDK unit suite, Python runtime validation, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index cf57ae0d40..e367408850 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml)(仅 pull request)在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26、Python SDK 单元测试套件与[发布形态的 Linux x64 Python 运行时验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md),串行参考流程(在 `ci-master.yml` 中)仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml)(仅 pull request)在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md))。标准托管 job 保留 Node 22.19、Node 26、Python SDK 单元测试套件,并在每个已发布原生目标上运行[安装后 wheel Python 运行时验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md);串行参考流程(在 `ci-master.yml` 中)仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 三项 Linux 主作业、Node 兼容性、Python SDK 单元测试套件、Python 运行时验证和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml new file mode 100644 index 0000000000..93a20e387f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.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 .agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +2026-08-23-installed-python-wheel-black-box-ci.md: f2b5bd0edeb02a5d72e8010c62c3cfb59ee95c5d +2026-08-23-installed-python-wheel-black-box-ci.zh.md: fb0f5fb2da676f1a24a2630bd45f005f46a3095e diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md new file mode 100644 index 0000000000..f2b5bd0ede --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -0,0 +1,51 @@ +# Agent Note: Installed-wheel Python runtime pull-request validation + +Status: implemented + +English | [中文](2026-08-23-installed-python-wheel-black-box-ci.zh.md) + +## Problem + +The Python SDK unit suite drives fake peers, while the packaged-runtime workflow can run the source SDK against a newly built executable before either Python distribution exists. Its clean virtual environment exercises only the default and MCP cases, and required pull-request CI builds only Linux x64. A source checkout, editable install, mismatched SDK/runtime pair, broken native wheel, platform-specific closure, or real-provider integration can therefore escape the evidence that blocks a merge. + +## Decision + +### Installed artifact boundary + +The required Python runtime workflow builds the pure SDK wheel and each platform runtime wheel before behavior tests. Every native target installs those two local files into a new Python 3.10 virtual environment, changes to a temporary directory outside the repository, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and invokes only the public Python modules plus the packaged executable. + +The black-box harness rejects a non-venv process, repository-relative working directory, source or editable import, unequal distribution versions, an SDK dependency that does not exactly pin the runtime version, an executable outside the installed runtime package, or an executable absent from the runtime distribution record. This provenance check runs before the first agent request, so a behavior pass cannot conceal that the wrong code ran. + +### Keyless behavior + +Every target runs the complete packaged-runtime scenario set after installation. A local SSE model keeps outputs deterministic while the public SDK exercises the default configuration, an external complete configuration, persistent PTY and editor behavior, worker-thread code and workflow execution, ripgrep-backed search, external stdio MCP discovery and execution, model-visible and durable snapshots, JSONL/Zstandard persistence, direct JSON-RPC, and shutdown. A restart snapshot launches two complete SDK runtime processes against one persistence root and pins their isolated model histories, high-level results, and separate durable logs. The installed run replaces the source-SDK pre-wheel run; the executable and wheel are tested together once rather than maintaining two behavior inventories. + +Linux additionally retains its manylinux 2.28 clean-install smoke and GLIBC checks. macOS retains deployment-target and native helper checks. These platform constraints supplement the common black-box behavior rather than substituting for it. + +### Real DeepSeek API + +Trusted pull requests run a second installed-wheel check on every native target with `DEEPSEEK_API_KEY_EXTERNAL`, mapped only into a preflight and the live test step. The preflight fails when the secret is empty, so the provider suite cannot self-skip to green. The test starts the public SDK against `https://api.deepseek.com`, asks the model to write an exact sentinel file through Bash, asks a second turn in the same session to read it, and verifies the external bytes, final responses, completed turn reasons, model-requested tool calls, and the existence and Zstandard framing of its session log. Decoded record content and completed-turn durability are deterministic keyless obligations owned by the restart snapshot rather than inferred from compressed live-provider bytes. + +Fork and Dependabot pull requests never receive the repository secret. Their native jobs run the complete keyless path and skip both secret-bearing steps; `pull_request_target` is forbidden because it would execute untrusted code with the key. + +### Required targets + +The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, and macOS arm64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. Windows has no runtime wheel in the platform manifest and is not claimed by this decision. + +## Existing decisions and supersession + +This decision supersedes the single-target topology in the archived [required Python runtime pull-request validation](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md) while retaining its requirement that the real executable, snapshots, wheels, and clean installation meet before merge. The [single-file Python SDK runtime distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) remains authoritative for SEA packaging, the closed dependency set, native sidecars, wheel tags, and release artifacts. + +## Alternatives considered + +**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the three published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. + +**Run full behavior before wheel construction and keep two small installed smokes.** Rejected because that proves the executable against source imports, then proves too little through the distribution users install. The clean installed environment is the stronger common location for the same scenarios. + +**Use keyless model emulation only.** Rejected because a local SSE endpoint cannot prove authentication, request compatibility, streaming, tool-call interpretation, or a complete turn against the real provider. + +**Expose the key to forked pull requests through `pull_request_target`.** Rejected because arbitrary fork code could exfiltrate the repository secret. Missing credentialed evidence on an untrusted ref is explicit and security-preserving; trusted heads and post-merge provider CI retain the live signal. + +## Consequences + +Every pull request pays for three native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md new file mode 100644 index 0000000000..fb0f5fb2da --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 安装后 Python wheel 黑盒拉取请求验证 + +Status: implemented + +[English](2026-08-23-installed-python-wheel-black-box-ci.md) | 中文 + +## Problem + +Python SDK 单元测试驱动 fake peer,而打包运行时工作流可以在两个 Python distribution 尚未生成时,用源码 SDK 驱动新构建的可执行文件。干净虚拟环境只覆盖默认与 MCP 场景,必需的拉取请求 CI 也只构建 Linux x64。因此,源码 checkout、editable install、不匹配的 SDK/运行时组合、损坏的原生 wheel 包、平台相关闭包或真实提供方集成都可能绕过阻止合并的证据。 + +## Decision + +### 安装产物边界 + +必需的 Python 运行时工作流先构建纯 SDK wheel 包与各平台运行时 wheel 包,再进行行为测试。每个原生目标都把这两个本地文件安装进新的 Python 3.10 虚拟环境,切换到仓库外的临时目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,并且只调用公开 Python 模块与打包后的可执行文件。 + +黑盒测试会拒绝非 venv 进程、仓库内工作目录、源码或 editable import、不相等的 distribution 版本、未精确固定运行时版本的 SDK 依赖、位于已安装运行时包之外的可执行文件,以及未出现在运行时 distribution 记录中的可执行文件。该来源校验发生在首个 agent 请求之前,因此行为通过也不能掩盖实际运行了错误代码。 + +### Keyless 行为 + +每个目标都会在安装后运行完整的打包运行时场景。一个本地 SSE mock 模型提供确定性输出,公开 SDK 则覆盖默认配置、外部完整配置、持久 PTY 与 editor 行为、worker thread 代码与 workflow 执行、基于 ripgrep 的搜索、外部 stdio MCP 发现与执行、模型可见及持久化快照、JSONL/Zstandard 持久化、直接 JSON-RPC 与关闭。Restart 快照针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。安装后运行取代 wheel 构建前的源码 SDK 运行,因此可执行文件与 wheel 包共同接受一次验证,而不是维护两套行为清单。 + +Linux 另外保留 manylinux 2.28 干净安装冒烟测试与 GLIBC 检查。macOS 保留部署目标与原生 helper 检查。这些平台约束补充共同黑盒行为,不能替代它。 + +### 真实 DeepSeek API + +可信拉取请求会在每个原生目标上运行第二项安装后 wheel 检查,并且只在预检与 live 测试步骤中把 `DEEPSEEK_API_KEY_EXTERNAL` 映射进去。密钥为空时预检失败,因此提供方测试不能通过自行 skip 产生假绿。该测试通过公开 SDK 访问 `https://api.deepseek.com`,要求模型通过 Bash 写入内容精确的 sentinel 文件,再在同一 session 的第二个轮次中读取它,并校验外部文件字节、最终响应、已完成的轮次结束原因、模型请求的工具调用,以及 session 日志存在且采用 Zstandard framing。解码后的记录内容与已完成轮次的持久性是由 restart 快照负责的确定性 keyless 要求,不从压缩后的 live 提供方字节推断。 + +Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 job 运行完整 keyless 路径并跳过两个带密钥的步骤;禁止使用 `pull_request_target`,因为它会让不可信代码带着密钥执行。 + +### 必需目标 + +拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64 与 macOS arm64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。Windows 不在运行时平台 manifest 中,本决策不声称支持它。 + +## Existing decisions and supersession + +本决策取代已归档的[必需 Python 运行时拉取请求验证](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md)中的单目标拓扑,同时保留真实可执行文件、快照、wheel 包与干净安装必须在合并前相遇的要求。[单文件 Python SDK 运行时 distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)仍负责 SEA 打包、封闭依赖集合、原生 sidecar、wheel 包标签与发布产物。 + +## Alternatives considered + +**只保留 Linux x64 必需载体。** 否决:三个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 + +**在 wheel 构建前运行完整行为,并保留两个很小的安装后冒烟测试。** 否决:这只能证明可执行文件配合源码 import 工作,再通过 distribution 证明很少的行为。干净安装环境是在同一批场景中验证用户实际安装内容的更强位置。 + +**只使用 keyless 模型模拟。** 否决:本地 SSE endpoint 不能证明真实提供方的认证、请求兼容性、流式输出、工具调用解释或完整轮次。 + +**通过 `pull_request_target` 向 fork 拉取请求暴露密钥。** 否决:任意 fork 代码都可以窃取仓库密钥。不可信 ref 缺少带凭据证据是明确且保留安全性的结果;可信 head 与合并后提供方 CI 继续提供 live 信号。 + +## Consequences + +每个拉取请求都会承担三个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index ba17869f29..b9ba5e148e 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -21,10 +21,14 @@ on: required: false default: false ci: - description: Run as the required Linux x64 Python runtime pull-request check. + description: Run as the required all-target Python runtime pull-request check. type: boolean required: false default: false + secrets: + DEEPSEEK_API_KEY_EXTERNAL: + description: Real DeepSeek API key for trusted installed-wheel pull-request tests. + required: false workflow_dispatch: inputs: targets: @@ -243,13 +247,6 @@ jobs: echo "exe=$exe" >> "$GITHUB_OUTPUT" echo "wheel=$wheel" >> "$GITHUB_OUTPUT" - - name: Full-turn SDK, executable snapshot, and direct-binary smoke - run: >- - uv run --python 3.10 --group test --project python/sdk - python scripts/smoke-python-runtime.py - --scenario all - --exe "${{ steps.runtime.outputs.exe }}" - - name: Build release-shaped runtime wheel run: >- python scripts/build-python-release.py @@ -273,10 +270,53 @@ jobs: "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ "dist-python/$SDK_WHEEL" \ "dist-python/$RUNTIME_WHEEL" - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ - --scenario sdk-default - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ - --scenario sdk-mcp + + - name: Run installed-wheel keyless black-box tests + run: | + set -euo pipefail + blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox" + mkdir -p "$blackbox_root" + cd "$blackbox_root" + env -u PYTHONPATH -u DSH_RUNTIME_MODE \ + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ + --scenario all \ + --installed-wheel + + - name: Preflight installed-wheel real API test + if: >- + inputs.ci + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + run: | + set -euo pipefail + if [ -z "${DEEPSEEK_API_KEY:-}" ]; then + echo "::error::DEEPSEEK_API_KEY_EXTERNAL is empty; the installed-wheel real API test cannot self-skip." + exit 1 + fi + + - name: Run installed-wheel real API black-box test + if: >- + inputs.ci + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + DEEPSEEK_BASE_URL: https://api.deepseek.com + run: | + set -euo pipefail + blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox-live" + mkdir -p "$blackbox_root" + cd "$blackbox_root" + env -u PYTHONPATH -u DSH_RUNTIME_MODE \ + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ + --scenario sdk-live \ + --installed-wheel - name: Check Linux GLIBC requirements if: runner.os == 'Linux' @@ -314,8 +354,10 @@ jobs: docker run --rm -e RUNTIME_WHEEL -e SDK_WHEEL -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk /tmp/dsh-sdk/bin/python -m pip install "/work/dist-python/$SDK_WHEEL" "/work/dist-python/$RUNTIME_WHEEL" - /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default - /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp + mkdir -p /tmp/dsh-sdk-manylinux-smoke + cd /tmp/dsh-sdk-manylinux-smoke + env -u PYTHONPATH -u DSH_RUNTIME_MODE /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default --installed-wheel + env -u PYTHONPATH -u DSH_RUNTIME_MODE /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp --installed-wheel ' - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e540c1cb15..1f02c2919f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,17 +294,18 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # One native target makes the complete release-shaped Python path required - # without duplicating platform-independent behavior across the release matrix. - # The reusable builder owns the executable, snapshot, wheel, clean-install, - # GLIBC, and manylinux checks; release validation retains all native targets. + # The reusable builder owns each published executable, wheel, clean-install, + # keyless black-box, and trusted real-API path. All native release targets are + # required because a platform wheel cannot be validated by another carrier. python-runtime: if: github.event_name == 'pull_request' - name: python runtime / release-shaped Linux x64 + name: python runtime / release-shaped matrix uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ci: true + secrets: + DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} # The pull-request Windows signals cover complementary hosts. The two fast # win32 toolchain surfaces (workspace build, production site) execute with diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 0e515656a3..75c657c153 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 6d326a1ac273e9594cbea4714e1bc9449f5e5ca7 -development.zh.md: d07ce7f95a195ea0fe24fa44529dc53b527c766a +development.md: e96be7af10e0008cc0fe5dea4ca51529f102fd7b +development.zh.md: e4ca1c980c36fdea381e9b8a6c615276e279b4d2 diff --git a/python/development.md b/python/development.md index 6d326a1ac2..e96be7af10 100644 --- a/python/development.md +++ b/python/development.md @@ -27,14 +27,16 @@ uv run --project python/sdk pytest `python/sdk/tests/test_bundled_runtime.py` exercises available bundled carriers and skips a carrier when its artifact has not been built. For repository-wide test policy, see [Testing](../docs/testing.md). -That suite drives fake runtime peers. `scripts/smoke-python-runtime.py` drives the real packaged runtime instead, and the required `python-runtime` CI job runs every scenario against a freshly built executable: +That suite drives fake runtime peers. `scripts/smoke-python-runtime.py` drives the packaged runtime instead. The required `python-runtime` CI job builds every published native target, installs the matching SDK and runtime wheels into a new Python 3.10 virtual environment, runs outside the checkout with `PYTHONPATH` and `DSH_RUNTIME_MODE` unset, proves that both modules and the executable came from those distributions, and then runs every keyless scenario. A focused local source-SDK run can select one built executable and scenario: ```sh uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 ``` -Two scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the checked-in minimal composition's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job; it drops the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)). `advanced/` pins the SDK result and the persisted session logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. +Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the checked-in minimal composition's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job; it drops the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)). `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. + +Trusted pull requests also run `--scenario sdk-live --installed-wheel` on every native target. That scenario performs two tool-using turns against `https://api.deepseek.com`, verifies the created file externally, and fails when the repository secret is absent instead of self-skipping. Fork and Dependabot pull requests run the complete keyless installed-wheel path but receive no key. An interactive smoke test needs `DEEPSEEK_API_KEY` in the environment or repository-root `.env`: diff --git a/python/development.zh.md b/python/development.zh.md index d07ce7f95a..e4ca1c980c 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -27,14 +27,16 @@ uv run --project python/sdk pytest `python/sdk/tests/test_bundled_runtime.py` 会运行可用的内置载体;某个载体的产物尚未构建时,会跳过该载体。仓库级测试政策见 [测试](../docs/testing.zh.md)。 -该套件面向的是伪造的运行时对端。`scripts/smoke-python-runtime.py` 面向真实的打包运行时;必需的 `python-runtime` CI 任务会用新构建的可执行文件运行全部场景: +该套件面向的是伪造的运行时对端。`scripts/smoke-python-runtime.py` 面向打包运行时。必需的 `python-runtime` CI 任务会构建每个已发布原生目标,把匹配的 SDK wheel 包与运行时 wheel 包安装进新的 Python 3.10 虚拟环境,在 checkout 外清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE` 后运行,证明两个模块及可执行文件都来自这些 distribution,然后运行全部 keyless 场景。聚焦的本地源码 SDK 运行可以选择一个已构建可执行文件与场景: ```sh uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 ``` -其中两个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定了签入的极简组合所组装的系统提示词、对外公布的工具 schema 以及模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败;它会丢弃动态运行时上下文快照——同一组合在 macOS 上会发出它,在 Linux 上不会([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))。`advanced/` 固定 SDK 结果与持久化的会话日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 +其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定了签入的极简组合所组装的系统提示词、对外公布的工具 schema 以及模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败;它会丢弃动态运行时上下文快照——同一组合在 macOS 上会发出它,在 Linux 上不会([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 + +可信拉取请求还会在每个原生目标上运行 `--scenario sdk-live --installed-wheel`。该场景面向 `https://api.deepseek.com` 执行两个使用工具的轮次,从外部验证已创建文件,并在仓库密钥缺失时失败而不是自行 skip。Fork 与 Dependabot 拉取请求会运行完整的 keyless 安装后 wheel 路径,但不会获得密钥。 交互式冒烟测试需要环境变量或仓库根目录 `.env` 中存在 `DEEPSEEK_API_KEY`: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 1c84f28c78..116bb84e3c 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -218,7 +218,7 @@ describe('CI workflow', () => { expect(config).not.toContain('packages/lsp/lsp-stdio/src/instance.ts') }) - it('requires one release-shaped Python runtime target on every pull request', () => { + it('requires release-shaped Python runtime validation on every published target', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') const pythonRuntime = workflowJob(workflow, 'python-runtime') const aggregate = workflowJob(workflow, 'all-checks-passed') @@ -228,12 +228,15 @@ describe('CI workflow', () => { expect(pythonRuntime).toMatchObject({ if: "github.event_name == 'pull_request'", - name: 'python runtime / release-shaped Linux x64', + name: 'python runtime / release-shaped matrix', uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { - targets: 'node24-linux-x64', + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', ci: true, }, + secrets: { + DEEPSEEK_API_KEY_EXTERNAL: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}', + }, }) expect(aggregate.needs).toContain('python-runtime') }) @@ -382,7 +385,7 @@ describe('Python release workflows', () => { const call = workflowEvent(workflow, 'workflow_call') const plan = workflowJob(workflow, 'plan') const build = workflowJob(workflow, 'build') - if (!isRecord(call.inputs) || !Array.isArray(plan.steps) || !Array.isArray(build.steps)) { + if (!isRecord(call.inputs) || !isRecord(call.secrets) || !Array.isArray(plan.steps) || !Array.isArray(build.steps)) { throw new TypeError('Python wheel builder must define workflow_call inputs and plan steps') } @@ -390,11 +393,20 @@ describe('Python release workflows', () => { const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') + const installedKeyless = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests') + const realApiPreflight = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test') + const installedRealApi = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test') + if (!isRecord(installedKeyless) || !isRecord(realApiPreflight) || !isRecord(installedRealApi)) { + throw new TypeError('Python wheel builder must define installed-wheel keyless and real API steps') + } expect(call.inputs).toHaveProperty('targets') expect(call.inputs).toMatchObject({ ci: { type: 'boolean', default: false }, release: { type: 'boolean', default: false }, }) + expect(call.secrets).toMatchObject({ + DEEPSEEK_API_KEY_EXTERNAL: { required: false }, + }) expect(workflow.concurrency).toMatchObject({ group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}', }) @@ -419,6 +431,26 @@ describe('Python release workflows', () => { expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) expect(JSON.stringify(macosCheck)).toContain('scripts/check-macos-deployment-target.py') expect(JSON.stringify(macosCheck)).toContain('$EXE-spawn-helper') + expect(JSON.stringify(installedKeyless)).toContain('--scenario all') + expect(JSON.stringify(installedKeyless)).toContain('--installed-wheel') + expect(JSON.stringify(installedKeyless)).toContain('env -u PYTHONPATH') + expect(JSON.stringify(installedKeyless)).toContain('-u DSH_RUNTIME_MODE') + expect(realApiPreflight).toMatchObject({ + env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' }, + }) + expect(String(realApiPreflight.if)).toContain('inputs.ci') + expect(String(realApiPreflight.if)).toContain('head.repo.fork') + expect(String(realApiPreflight.if)).toContain('dependabot[bot]') + expect(installedRealApi).toMatchObject({ + env: { + DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}', + DEEPSEEK_BASE_URL: 'https://api.deepseek.com', + }, + }) + expect(installedRealApi.if).toBe(realApiPreflight.if) + expect(JSON.stringify(installedRealApi)).toContain('--scenario sdk-live') + expect(JSON.stringify(installedRealApi)).toContain('--installed-wheel') + expect(JSON.stringify(installedRealApi)).toContain('-u DSH_RUNTIME_MODE') expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED') }) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 6c4010e427..71e7100268 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -5,6 +5,8 @@ from __future__ import annotations import argparse import difflib +import importlib +import importlib.metadata import json import os import queue @@ -22,6 +24,7 @@ if TYPE_CHECKING: EXPECTED_TEXT = "runtime smoke ok" +LIVE_API_SENTINEL = "PYTHON_SDK_LIVE_OK" CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." @@ -47,6 +50,12 @@ SNAPSHOT_SESSION_ID = "advanced-executable" SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else." SNAPSHOT_WORKFLOW_CHILD_PROMPT = "Reply with exactly WORKFLOW_CHILD_OK and nothing else." SNAPSHOT_FINAL_TEXT = "ADVANCED_EXECUTABLE_OK" +RESTART_FIRST_PROMPT = "Complete the first isolated Python SDK process turn." +RESTART_FIRST_TEXT = "PROCESS_ONE_OK" +RESTART_SECOND_PROMPT = "Complete the second isolated Python SDK process turn." +RESTART_SECOND_TEXT = "PROCESS_TWO_OK" +RESTART_FIRST_SESSION_ID = "process-one" +RESTART_SECOND_SESSION_ID = "process-two" SNAPSHOT_PLUGIN_CODE = """\ return (ctx) => { harness.registerTool(ctx, harness.defineTool({ @@ -78,6 +87,10 @@ MINIMAL_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal" ) MINIMAL_SNAPSHOT_FILENAMES = ("model-visible.json",) +RESTART_SNAPSHOT_DIRECTORY = ( + Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart" +) +RESTART_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.1.jsonl", "session.2.jsonl") # The agent loop's dynamic runtime-context snapshot is the one model-visible message this # expected output cannot carry: the same composition emits it on macOS and not on Linux # (deepseek-harness#2488), and the file must replay on both. Everything else is compared. @@ -349,6 +362,8 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: WORKFLOW_PROMPT, FS_SEARCH_PROMPT, MCP_PROMPT, + RESTART_FIRST_PROMPT, + RESTART_SECOND_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -370,6 +385,16 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "code": {"host": SNAPSHOT_PLUGIN_CODE}, }, ) + if prompt == RESTART_FIRST_PROMPT: + return text_chunks(RESTART_FIRST_TEXT) + if prompt == RESTART_SECOND_PROMPT: + if any( + isinstance(message, dict) + and RESTART_FIRST_TEXT in message_text(message.get("content")) + for message in messages + ): + raise AssertionError("second isolated process inherited the first process history") + return text_chunks(RESTART_SECOND_TEXT) if prompt == CODE_PROMPT: assert_advertised_tool(body, "run_code") return tool_call_chunks( @@ -685,19 +710,35 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-live", "direct"), default="all", ) parser.add_argument("--exe", type=Path) + parser.add_argument( + "--installed-wheel", + action="store_true", + help="require a clean virtual environment containing matching installed SDK and runtime wheels", + ) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"} and args.exe is None: + if args.installed_wheel and args.exe is not None: + parser.error("--installed-wheel resolves the wheel's own runtime and cannot be combined with --exe") + if args.scenario == "sdk-live" and not args.installed_wheel: + parser.error("--scenario sdk-live requires --installed-wheel") + if args.installed_wheel: + args.exe = assert_installed_wheel_environment() + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "sdk-restart", "direct"} and args.exe is None: parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") - if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot"}: - parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, or all") + if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot", "sdk-restart"}: + parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, sdk-restart, or all") if args.exe is not None and not args.exe.is_file(): parser.error(f"runtime executable does not exist: {args.exe}") + if args.scenario == "sdk-live": + smoke_sdk_live() + print("smoke-python-runtime: sdk-live passed") + return + with MockModel() as model: if args.scenario in {"all", "sdk-default"}: smoke_sdk_default(model.url) @@ -715,6 +756,9 @@ def main() -> None: if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) + if args.scenario in {"all", "sdk-restart"}: + assert args.exe is not None + smoke_sdk_restart_snapshot(model.url, args.exe.resolve(), args.update_snapshots) if args.scenario in {"all", "direct"}: assert args.exe is not None smoke_direct(model.url, args.exe.resolve()) @@ -723,6 +767,144 @@ def main() -> None: print(f"smoke-python-runtime: {args.scenario} passed") +def assert_installed_wheel_environment() -> Path: + """Prove that this process imports matching non-editable wheel installations.""" + if sys.prefix == sys.base_prefix: + raise AssertionError("installed-wheel smoke must run inside a virtual environment") + if os.environ.get("PYTHONPATH"): + raise AssertionError("installed-wheel smoke requires PYTHONPATH to be unset") + if os.environ.get("DSH_RUNTIME_MODE"): + raise AssertionError("installed-wheel smoke requires DSH_RUNTIME_MODE to be unset") + + repo_root = Path(__file__).resolve().parent.parent + cwd = Path.cwd().resolve() + if cwd.is_relative_to(repo_root): + raise AssertionError(f"installed-wheel smoke must run outside the repository, got {cwd}") + + sdk_version = importlib.metadata.version("deepseek-harness-sdk") + runtime_version = importlib.metadata.version("deepseek-harness-runtime-bin") + if sdk_version != runtime_version: + raise AssertionError( + f"installed SDK/runtime versions differ: {sdk_version} != {runtime_version}" + ) + expected_runtime_requirement = f"deepseek-harness-runtime-bin=={sdk_version}" + requirements = importlib.metadata.requires("deepseek-harness-sdk") or [] + if expected_runtime_requirement not in requirements: + raise AssertionError( + f"installed SDK does not require {expected_runtime_requirement}: {requirements}" + ) + + prefix = Path(sys.prefix).resolve() + imported: dict[str, Path] = {} + for name in ("deepseek_harness", "deepseek_harness_runtime"): + module = importlib.import_module(name) + module_file = getattr(module, "__file__", None) + if not isinstance(module_file, str): + raise AssertionError(f"installed module {name} has no filesystem location") + path = Path(module_file).resolve() + if not path.is_relative_to(prefix): + raise AssertionError(f"installed module {name} came from outside the virtual environment: {path}") + if path.is_relative_to(repo_root): + raise AssertionError(f"installed module {name} came from the repository checkout: {path}") + imported[name] = path + + runtime_module = sys.modules["deepseek_harness_runtime"] + executable = runtime_module.bundled_runtime_path().resolve() + runtime_package = imported["deepseek_harness_runtime"].parent + if not executable.is_relative_to(runtime_package): + raise AssertionError(f"bundled runtime came from outside the installed runtime wheel: {executable}") + runtime_files = importlib.metadata.files("deepseek-harness-runtime-bin") or [] + if not any(Path(file).name == executable.name for file in runtime_files): + raise AssertionError(f"runtime executable is absent from installed distribution records: {executable}") + return executable + + +def smoke_sdk_live() -> None: + """Run a real-model, tool-using two-turn task through installed wheels.""" + from deepseek_harness import DeepSeekHarness + + api_key = os.environ.get("DEEPSEEK_API_KEY") + base_url = os.environ.get("DEEPSEEK_BASE_URL") + if not api_key: + raise AssertionError("sdk-live requires DEEPSEEK_API_KEY") + if not base_url: + raise AssertionError("sdk-live requires an explicit DEEPSEEK_BASE_URL") + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-live-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + marker = root / "live-api-marker.txt" + session_id = "installed-wheel-live-api" + create_prompt = ( + "Use the bash tool to create the file at the absolute path below with exactly one line " + f"containing {LIVE_API_SENTINEL}. Then reply with exactly {LIVE_API_SENTINEL}.\n{marker}" + ) + verify_prompt = ( + "Use a tool to read the file created in the previous turn. " + f"If its only line is {LIVE_API_SENTINEL}, reply with exactly {LIVE_API_SENTINEL}." + ) + with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + cwd=str(root), + session_root=str(sessions), + api_key=api_key, + base_url=base_url, + request_timeout_seconds=180, + ) as harness: + created = harness.run(create_prompt, session_id=session_id) + verified = harness.run(verify_prompt, session_id=session_id) + + for label, result in (("create", created), ("verify", verified)): + if result.finish_reason != "completed": + event_types = [event.get("type") for event in result.events] + turn_end_data = next( + (event.get("data") for event in reversed(result.events) if event.get("type") == "turn/end"), + None, + ) + turn_end = safe_turn_end(turn_end_data) + raise AssertionError( + f"{label} turn ended with {result.finish_reason!r}; " + f"final={result.final_response!r}; turn_end={turn_end!r}; events={event_types}" + ) + if not any(event.get("type") == "tool/call" for event in result.events): + raise AssertionError( + f"{label} turn made no model-requested tool call; " + f"final={result.final_response!r}" + ) + if result.final_response.strip() != LIVE_API_SENTINEL: + raise AssertionError(f"{label} turn returned {result.final_response!r}") + if not marker.is_file(): + raise AssertionError(f"real-model tool turn did not create {marker}") + if marker.read_bytes() != f"{LIVE_API_SENTINEL}\n".encode(): + raise AssertionError(f"real-model tool turn wrote unexpected bytes to {marker}") + assert_zstd_session_log(sessions) + + +def safe_turn_end(value: object) -> object: + """Project a live-provider failure without retaining credential-bearing text.""" + if not isinstance(value, dict): + return value + reason = value.get("reason") + if not isinstance(reason, dict): + return {"turn": value.get("turn"), "reason": reason} + error = reason.get("error") + safe_error = None + if isinstance(error, dict): + safe_error = { + key: error.get(key) + for key in ("code", "status") + if error.get(key) is not None + } + return { + "turn": value.get("turn"), + "reason": { + "kind": reason.get("kind"), + **({"error": safe_error} if safe_error is not None else {}), + }, + } + + def smoke_sdk_default(base_url: str) -> None: from deepseek_harness import DeepSeekHarness @@ -915,6 +1097,61 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) ) +def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: + """Snapshot two isolated sessions across complete SDK runtime restarts.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-restart-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(CUSTOM_CORDIS) + first_request = len(MockModelHandler.requests) + + def run(prompt: str, session_id: str) -> "RunResult": + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + return harness.run(prompt, session_id=session_id) + + first = run(RESTART_FIRST_PROMPT, RESTART_FIRST_SESSION_ID) + second = run(RESTART_SECOND_PROMPT, RESTART_SECOND_SESSION_ID) + requests = MockModelHandler.requests[first_request:] + if len(requests) != 2: + raise AssertionError(f"restart snapshot expected two model requests: {requests}") + if first.final_response != RESTART_FIRST_TEXT or second.final_response != RESTART_SECOND_TEXT: + raise AssertionError( + f"restart snapshot responses differ: {first.final_response!r}, {second.final_response!r}" + ) + + logs = read_session_logs(sessions) + expected_ids = {RESTART_FIRST_SESSION_ID, RESTART_SECOND_SESSION_ID} + if set(logs) != expected_ids: + raise AssertionError(f"restart snapshot expected two durable sessions: {sorted(logs)}") + for session_id, expected in ( + (RESTART_FIRST_SESSION_ID, RESTART_FIRST_TEXT), + (RESTART_SECOND_SESSION_ID, RESTART_SECOND_TEXT), + ): + records = logs[session_id] + if sum(record.get("type") == "turn/end" for record in records) != 1: + raise AssertionError(f"restart snapshot {session_id} has an unexpected turn count") + if expected not in render_jsonl(records): + raise AssertionError(f"restart snapshot durable log has no {expected}") + + files = build_restart_snapshot_files(first, second, requests, logs, root, sessions) + compare_snapshot_files( + files, update_snapshots, RESTART_SNAPSHOT_DIRECTORY, RESTART_SNAPSHOT_FILENAMES, + ) + + def smoke_direct(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary: root = Path(temporary).resolve() @@ -1202,6 +1439,69 @@ def build_snapshot_files( return files +def build_restart_snapshot_files( + first: "RunResult", + second: "RunResult", + requests: list[dict[str, object]], + logs: dict[str, list[dict[str, object]]], + cwd: Path, + sessions: Path, +) -> dict[str, str]: + """Render two SDK processes, isolated model histories, and durable logs.""" + replacements = [ + (str(sessions), "{{sessions}}"), + (str(cwd), "{{cwd}}"), + (RESTART_FIRST_SESSION_ID, "{{session-1}}"), + (RESTART_SECOND_SESSION_ID, "{{session-2}}"), + ] + result_value = [ + { + "session_id": result.session_id, + "final_response": result.final_response, + "finish_reason": result.finish_reason, + "eventTypes": [event.get("type") for event in result.events], + "notificationMethods": [notification.method for notification in result.notifications], + "session_root": result.session_root, + } + for result in (first, second) + ] + request_value = [ + { + "model": request.get("model"), + "messages": restart_request_messages(request), + "toolNames": sorted(advertised_tool_names(request)), + } + for request in requests + ] + return { + "result.json": json.dumps( + normalize_snapshot_value(result_value, replacements), indent=2, ensure_ascii=False, + ) + "\n", + "requests.json": json.dumps( + normalize_snapshot_value(request_value, replacements), indent=2, ensure_ascii=False, + ) + "\n", + "session.1.jsonl": render_jsonl(project_session_snapshot([ + normalize_snapshot_value(record, replacements) for record in logs[RESTART_FIRST_SESSION_ID] + ])), + "session.2.jsonl": render_jsonl(project_session_snapshot([ + normalize_snapshot_value(record, replacements) for record in logs[RESTART_SECOND_SESSION_ID] + ])), + } + + +def restart_request_messages(request: dict[str, object]) -> list[object]: + """Project model history while tokenizing composition-owned system prose.""" + messages = request.get("messages") + if not isinstance(messages, list): + raise AssertionError(f"restart snapshot request has no messages: {request}") + return [ + {"role": "system", "content": "{{system}}"} + if isinstance(message, dict) and message.get("role") == "system" + else message + for message in messages + ] + + def snapshot_workflow_run_id(result: "RunResult") -> str: """Return the one workflow run id emitted by the advanced scenario.""" run_ids: set[str] = set() diff --git a/scripts/snapshots/python-sdk-single-exe/restart/requests.json b/scripts/snapshots/python-sdk-single-exe/restart/requests.json new file mode 100644 index 0000000000..dd5f925294 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/restart/requests.json @@ -0,0 +1,58 @@ +[ + { + "model": "smoke-model", + "messages": [ + { + "role": "system", + "content": "{{system}}" + }, + { + "role": "user", + "content": "Complete the first isolated Python SDK process turn." + } + ], + "toolNames": [ + "cordis_define", + "cordis_inspect_list", + "cordis_inspect_query", + "cordis_inspect_self", + "cordis_run", + "cordis_stop", + "cordis_undefine", + "job_kill", + "job_list", + "job_output", + "run_code", + "subagent", + "workflow" + ] + }, + { + "model": "smoke-model", + "messages": [ + { + "role": "system", + "content": "{{system}}" + }, + { + "role": "user", + "content": "Complete the second isolated Python SDK process turn." + } + ], + "toolNames": [ + "cordis_define", + "cordis_inspect_list", + "cordis_inspect_query", + "cordis_inspect_self", + "cordis_run", + "cordis_stop", + "cordis_undefine", + "job_kill", + "job_list", + "job_output", + "run_code", + "subagent", + "workflow" + ] + } +] diff --git a/scripts/snapshots/python-sdk-single-exe/restart/result.json b/scripts/snapshots/python-sdk-single-exe/restart/result.json new file mode 100644 index 0000000000..32911ef466 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/restart/result.json @@ -0,0 +1,94 @@ +[ + { + "session_id": "{{session-1}}", + "final_response": "PROCESS_ONE_OK", + "finish_reason": "completed", + "eventTypes": [ + "agent/inbox/spliced", + "turn/start", + "agent/inbox/spliced", + "step/start", + "user/message", + "session/title", + "request/header", + "request/context", + "session-log-deepseek/delivery-accepted", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/message", + "step/end", + "turn/end" + ], + "notificationMethods": [ + "session.event", + "session.status", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.status" + ], + "session_root": "{{sessions}}" + }, + { + "session_id": "{{session-2}}", + "final_response": "PROCESS_TWO_OK", + "finish_reason": "completed", + "eventTypes": [ + "agent/inbox/spliced", + "turn/start", + "agent/inbox/spliced", + "step/start", + "user/message", + "session/title", + "request/header", + "request/context", + "session-log-deepseek/delivery-accepted", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/message", + "step/end", + "turn/end" + ], + "notificationMethods": [ + "session.event", + "session.status", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.status" + ], + "session_root": "{{sessions}}" + } +] diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl new file mode 100644 index 0000000000..38e5f98ff6 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"{{session-1}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Complete the first isolated Python","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-1}}","throughSeq":7}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_ONE_OK"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_ONE_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl new file mode 100644 index 0000000000..523f784dfa --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"{{session-2}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Complete the second isolated Python","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-2}}","throughSeq":7}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_TWO_OK"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_TWO_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 3c10f5d2d361504d3790a2c9057252f7d584f0ff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:31:37 +0800 Subject: [PATCH 210/248] fix(client): route UI copy through locale --- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 19 +- ...026-07-30-client-locale-full-rollout.zh.md | 19 +- ...8-23-locale-owned-client-ui-copy.i18n.yaml | 6 + .../2026-08-23-locale-owned-client-ui-copy.md | 42 ++ ...26-08-23-locale-owned-client-ui-copy.zh.md | 42 ++ .agents/skills/dsh-code-review/SKILL.md | 1 + .agents/skills/dsh-prose-standard/SKILL.md | 2 +- AGENTS.md | 5 +- .../access-confirmation/ui.expected.md | 2 +- .../search-card/grep-card.expected.txt | 4 +- apps/web/tests/web-search-round.e2e.ts | 2 +- package.json | 1 + packages/client/AGENTS.md | 8 +- packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 3 +- packages/client/locale/README.zh.md | 3 +- packages/client/locale/src/locales/en.ts | 14 + packages/client/locale/src/locales/zh.ts | 14 + .../src/client/PopupSelectView.tsx | 1 + .../src/client/chat/AssistantMarkdown.tsx | 5 +- .../src/client/chat/ChatView.tsx | 2 +- .../src/client/chat/CompactionCommandCard.tsx | 2 +- .../src/client/chat/CompactionItem.tsx | 6 +- .../src/client/chat/MessageItem.tsx | 2 +- .../src/client/chat/ReasoningRow.tsx | 2 +- .../src/client/chat/StatsLine.tsx | 25 +- .../ui-conversation/src/client/locales.ts | 98 +++ .../src/client/markdown-labels.ts | 26 + .../src/client/skeleton/ContextMeter.tsx | 4 +- .../src/client/skeleton/PermissionSelect.tsx | 14 +- .../tests/chat-branch-tails.client.spec.tsx | 2 +- .../tests/chat-stats.client.spec.tsx | 14 +- .../tests/chat-view.client.spec.tsx | 6 +- .../tests/coverage-tails.client.spec.tsx | 2 +- .../tests/reasoning-row.client.spec.tsx | 4 +- .../src/client/PermissionRow.tsx | 1 + .../ui-plan/src/client/PlanModeControl.tsx | 6 +- packages/client/ui-plan/src/client/locales.ts | 4 + .../tests/plan-mode-control.client.spec.tsx | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/ConnectionBanner.tsx | 4 +- .../client/ui-primitives/src/DiffBlock.tsx | 23 +- .../client/ui-primitives/src/HoverCard.tsx | 10 +- .../client/ui-primitives/src/JsonTree.tsx | 32 +- packages/client/ui-primitives/src/Modal.tsx | 4 +- .../client/ui-primitives/src/ReadBlock.tsx | 22 +- .../ui-primitives/src/RiskConfirmation.tsx | 3 + .../client/ui-primitives/src/SearchBlock.tsx | 28 +- .../ui-primitives/src/TerminalBlock.tsx | 28 +- .../client/ui-primitives/src/WebBlock.tsx | 29 +- packages/client/ui-primitives/src/index.ts | 11 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 6 +- .../ui-primitives/src/markdown/JsonBlock.tsx | 9 +- .../src/markdown/MarkdownText.tsx | 36 +- .../ui-primitives/src/markdown/render.tsx | 18 +- .../ui-primitives/tests/atoms.client.spec.tsx | 6 +- .../tests/code-block.client.spec.tsx | 8 +- .../tests/diff-block.client.spec.tsx | 8 +- .../tests/hover-card.client.spec.tsx | 18 +- .../tests/json-tree.client.spec.tsx | 10 +- .../ui-primitives/tests/labels.client.ts | 64 ++ .../tests/markdown-dom-parity.client.spec.tsx | 2 +- .../markdown-incremental.client.spec.tsx | 10 +- .../markdown-render-units.client.spec.tsx | 5 +- .../tests/markdown-test-components.tsx | 37 + .../tests/markdown.client.spec.tsx | 3 +- .../tests/read-block.client.spec.tsx | 8 +- .../tests/search-block.client.spec.tsx | 25 +- .../tests/terminal-block.client.spec.tsx | 8 +- .../tests/web-block.client.spec.tsx | 25 +- packages/client/ui-renderer/README.i18n.yaml | 4 +- packages/client/ui-renderer/README.md | 2 +- packages/client/ui-renderer/README.zh.md | 2 +- .../ui-renderer/src/client/DocumentTitle.tsx | 7 +- .../client/ui-renderer/src/client/app.tsx | 9 +- .../client/ui-renderer/src/client/index.ts | 2 +- .../ui-renderer/tests/app.client.spec.tsx | 2 + .../tests/document-title.client.spec.tsx | 10 +- .../client/ui-renderer/tests/locale.client.ts | 12 + .../tests/ui-renderer.client.spec.tsx | 2 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 6 +- .../src/client/DeepSeekOnboardingDialog.tsx | 6 +- .../src/client/EditorFooter.tsx | 10 +- .../src/client/ProviderEditor.tsx | 14 +- .../ui-settings-models/src/client/locales.ts | 18 +- .../ui-settings-models/src/onboarding-copy.ts | 14 - .../tests/components.client.spec.tsx | 6 +- .../tests/welcome-notice.client.spec.tsx | 7 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 2 +- .../sidebar-snapshot.client.spec.tsx.snap | 2 +- .../tests/sidebar-root.client.spec.tsx | 4 +- .../tests/sidebar-snapshot.client.spec.tsx | 3 + .../client/ui-skill/src/client/SkillRow.tsx | 4 +- .../client/ui-skill/src/client/locales.ts | 4 + .../tests/browser-plugin.client.spec.ts | 4 + .../ui-skill/tests/skill-row.client.spec.tsx | 2 +- .../src/client/SubagentHeaderLineage.tsx | 8 +- .../client/ui-subagent/src/client/locales.ts | 6 + packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 11 +- .../src/client/tool/components/ToolRow.tsx | 26 +- .../client/tool/models/primitive-labels.ts | 98 +++ .../client/tool/models/search-card-model.ts | 2 +- .../src/client/tool/models/tool-call-model.ts | 38 +- .../src/client/tool/models/web-card-model.ts | 12 +- .../client/tool/toolviews/GenericToolCard.tsx | 2 +- .../src/client/tool/toolviews/bash-sample.tsx | 8 +- .../tool/toolviews/file-mutation-row.tsx | 2 +- .../src/client/tool/toolviews/read-row.tsx | 2 +- .../src/client/tool/toolviews/search-row.tsx | 12 +- .../src/client/tool/toolviews/web-row.tsx | 12 +- .../ui-tool/tests/diff-card.client.spec.tsx | 2 +- .../ui-tool/tests/read-card.client.spec.tsx | 2 +- .../tests/terminal-card.client.spec.tsx | 4 +- .../ui-tool/tests/tool-row.client.spec.tsx | 48 +- .../ui-tool/tests/web-card.client.spec.tsx | 8 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../src/client/TrajectoryCell.tsx | 24 +- .../src/client/TrajectoryTable.tsx | 692 ++++++++++-------- .../src/client/TrajectoryTimeline.tsx | 63 +- .../src/client/TrajectoryTurn.tsx | 7 +- .../src/client/TrajectoryTurnHeader.tsx | 15 +- .../src/client/TrajectoryView.tsx | 20 +- .../ui-trajectory/src/client/copy-codes.ts | 4 + .../client/ui-trajectory/src/client/layout.ts | 111 +-- .../ui-trajectory/src/client/locales.ts | 382 +++++++++- .../ui-trajectory/src/client/timeline.ts | 9 +- .../src/client/trajectory-record.ts | 19 +- .../src/client/trajectory-snapshot-builder.ts | 3 +- .../ui-trajectory/tests/cell.client.spec.tsx | 21 +- .../tests/layout.client.spec.tsx | 18 +- .../ui-trajectory/tests/locale.client.ts | 21 + .../ui-trajectory/tests/table.client.spec.tsx | 37 +- .../ui-trajectory/tests/views.client.spec.tsx | 90 +-- .../src/client/PlanReviewPanel.tsx | 8 +- .../src/client/QuestionComposer.tsx | 6 +- .../ui-workspace/src/client/rows/Rows.tsx | 2 +- .../client/ui-workspace/src/client/tree.ts | 11 +- .../ui-workspace/tests/tree.client.spec.ts | 10 +- scripts/AGENTS.md | 2 +- scripts/run-gates.spec.ts | 11 +- scripts/run-gates.ts | 2 + scripts/verify-client-ui-i18n.spec.ts | 52 ++ scripts/verify-client-ui-i18n.ts | 329 +++++++++ 154 files changed, 2489 insertions(+), 896 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md create mode 100644 packages/client/ui-conversation/src/client/markdown-labels.ts create mode 100644 packages/client/ui-primitives/tests/labels.client.ts create mode 100644 packages/client/ui-primitives/tests/markdown-test-components.tsx create mode 100644 packages/client/ui-renderer/tests/locale.client.ts create mode 100644 packages/client/ui-tool/src/client/tool/models/primitive-labels.ts create mode 100644 packages/client/ui-trajectory/src/client/copy-codes.ts create mode 100644 packages/client/ui-trajectory/tests/locale.client.ts create mode 100644 scripts/verify-client-ui-i18n.spec.ts create mode 100644 scripts/verify-client-ui-i18n.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index c35962e7d3..66dff1b8ec 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab -2026-07-30-client-locale-full-rollout.zh.md: 427c9e5ef9c544a49e70b6ba8450511072f53a6e +2026-07-30-client-locale-full-rollout.md: aeb4deae28b0dfdb9ab75fd64fe3143958cd6910 +2026-07-30-client-locale-full-rollout.zh.md: a642b6062cb3dc7a2dfa22dd5d8cf7d9a02e3104 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 6701aefa45..aeb4deae28 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -1,4 +1,4 @@ -# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary +# Agent Note: Full client copy rollout onto the typed locale seat Status: implemented @@ -6,7 +6,7 @@ English | [中文](2026-07-30-client-locale-full-rollout.zh.md) ## Problem -After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization. +After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch, and how the zero-Cordis ui-primitives atoms receive copy without depending on the runtime. ## Decision @@ -14,16 +14,11 @@ After the typed locale standard seat landed (`locale:` on register → framework **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. -**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). +**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). -**The non-translation boundary (deliberate decisions, not debt):** +**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation. -- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim. -- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages. -- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately). -- **Boot copy stays hardcoded** (the framework-free boot page runs before the locale service exists). - -**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. +**Derivation layers keep display text out of identity.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank session titles and the Ungrouped label derive from the `blank` flag / absent `workspaceId`, while internal values stay empty or stable; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter. **Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). @@ -33,7 +28,7 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision. - **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently. -- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text. +- **Translate external or wire error data**: rejected because provider and protocol diagnostics are evidence searched and compared verbatim. Product-authored surrounding failure chrome is translated; externally authored data is not. - **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock. - **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic. @@ -41,5 +36,5 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. -- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. +- ui-primitives require localized label props, so adding a primitive render site also adds an explicit copy owner; omission fails typechecking instead of selecting a hidden language. - Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 427c9e5ef9..a642b6062c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -1,4 +1,4 @@ -# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界 +# Agent Note: client 文案全量接入 typed locale 席位 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## Problem -typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使未来的 agent(智能体)「补完」翻译。 +typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新,以及 zero-Cordis 的 ui-primitives 原子组件如何在不依赖运行时的情况下拿到文案。 ## Decision @@ -14,16 +14,11 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费方渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 +**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。 -**不翻译边界(刻意决定,不是欠账):** +**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。 -- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError 消息、wire 透出的 `error.message (code)` 原样呈现。 -- **设计字面量不进字典**:工具行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、整个 StatsLine——中英界面显示一致。 -- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。 -- **boot 文案保持硬编码**(不依赖框架的启动页运行早于 locale 服务可用)。 - -**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 +**派生层不让展示文本承担身份。** ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}`,由渲染组合字典模板;blank 会话标题和未分组 label 从 `blank` 标志/`workspaceId` 缺席派生,内部值保持为空或稳定;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数接收 `t` 参数。 **测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot 快照 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。 @@ -33,7 +28,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 已经为每个包注册一次,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。 - **给 ui-primitives 造 locale 上下文/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费方(ui-trajectory)陪跑。props 化让每个消费方独立决定。 -- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。 +- **翻译外部或 wire 错误数据**:否决。提供方与协议诊断是需要原样搜索和比对的证据。产品编写的外围失败 chrome 会翻译,外部编写的数据不会。 - **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。 - **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下「看得见搜不到」;占位行本无信息量,整体排除语义最稳。 @@ -41,5 +36,5 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。 -- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。 +- ui-primitives 要求本地化 label prop,因此新增原子组件渲染点也必须新增明确的文案 owner;遗漏会在类型检查失败,而不是选择隐藏语言。 - e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml new file mode 100644 index 0000000000..0d78347cb9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.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 .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md +2026-08-23-locale-owned-client-ui-copy.md: abcce4993c7155d2e280bd1185c305a6fb4bb562 +2026-08-23-locale-owned-client-ui-copy.zh.md: 3f24bf96277a0d6307e197d211b47dfb96c278cb diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md new file mode 100644 index 0000000000..abcce4993c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md @@ -0,0 +1,42 @@ +# Agent Note: Locale-owned client UI copy + +Status: implemented + +English | [中文](2026-08-23-locale-owned-client-ui-copy.zh.md) + +## Problem + +Typed locale namespaces and bilingual dictionary parity proved that registered dictionaries were complete, but they could not prove that presentation code used them. JSX text, accessibility attributes, formatter returns, and zero-Cordis primitive defaults could bypass `t` while every locale check remained green. The deferred and supposedly language-neutral exceptions recorded in the [initial full-rollout decision](2026-07-30-client-locale-full-rollout.md) accumulated into a mixed-language UI, especially in trajectory inspection and generic Tool cards. + +## Decision + +**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated. + +**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionBanner` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning. + +**Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state. + +**`verify-client-ui-i18n` enforces source ownership.** The TypeScript-AST check discovers every client TSX file, helper TS files under `ui-*`, and the web app source. It rejects natural-language JSX text, copy-bearing attributes and component props, literal JSX branches, label/copy data, named copy helpers, string-returning display formatters, and destructuring defaults. Locale dictionary owners and immutable language tokens are the narrow syntactic exclusions. Discovery refuses a narrowed corpus, unit fixtures pin admitted and excluded forms, and the check runs in the static CI and `hygiene` graphs. Dictionary-key parity remains a separate check: one gate proves copy enters the locale path, while the other proves both shipped languages implement that path. + +The product-authored error and design-literal exclusions, primitive defaults, and trajectory deferral in the [initial rollout](2026-07-30-client-locale-full-rollout.md) are superseded by this decision. Its label-thunk, typed-seat, browser-locale, date-formatting, and search-placeholder decisions remain active. + +## Verification + +The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface. + +## Alternatives considered + +**Rely on review and AGENTS.md alone.** Rejected because the existing rule and typed dictionaries coexisted with hundreds of bypasses; reviewers need a source-level failure at the introducing line. + +**Use a text regex or ban every string literal.** Rejected because TypeScript and JSX contain imports, CSS classes, discriminants, event names, SVG data, and user/wire values. Syntax-aware contexts provide useful signal without an ever-growing file allowlist, while the minimum discovery count prevents a falsely green narrowed scan. + +**Keep primitive fallback copy for convenient direct use.** Rejected because a fallback is itself an implicit locale choice. Required label props keep primitives framework-free and make each product render site name its copy owner. + +**Translate every string that reaches the DOM.** Rejected because authored data and protocol/code tokens are not product wording. Translating them corrupts evidence, identifiers, commands, paths, URLs, and provider diagnostics; only surrounding product chrome belongs to the locale system. + +## Consequences + +- Adding or changing client UI copy requires a typed dictionary key in both locales and behavior evidence for the affected render path. +- Pure primitives have larger explicit prop types, and tests provide deliberate label fixtures; this cost prevents hidden locale behavior. +- The AST check catches authored literal bypasses but cannot prove that an arbitrary dynamic string prop was translated. Types, dictionary parity, component tests, and review still own that semantic distinction. +- Boot markup that renders before the locale service and externally authored runtime data remain outside the dictionary path; product UI replaces boot copy after locale activation. diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md new file mode 100644 index 0000000000..3f24bf9627 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md @@ -0,0 +1,42 @@ +# Agent Note: locale 归属的 client UI 文案 + +Status: implemented + +[English](2026-08-23-locale-owned-client-ui-copy.md) | 中文 + +## Problem + +typed locale namespace 与双语字典对等性可以证明已注册字典完整,却无法证明展示代码使用了字典。JSX 文本、无障碍属性、格式化函数返回值和 zero-Cordis 原子组件默认值都可能绕过 `t`,而全部 locale 检查仍保持绿色。[最初的全量接入决策](2026-07-30-client-locale-full-rollout.zh.md)中缓做或假定为语言无关的例外逐渐形成混合语言 UI,trajectory 检查面和通用工具卡尤为明显。 + +## Decision + +**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。 + +**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionBanner` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 + +**本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。 + +**`verify-client-ui-i18n` 强制源码归属。** 基于 TypeScript AST 的检查会发现所有 client TSX 文件、`ui-*` 下的辅助 TS 文件和 web 应用源码;它拒绝自然语言 JSX 文本、承载文案的属性与组件 prop、JSX 字面量分支、label/copy 数据、具名文案辅助函数、返回字符串的展示格式化函数和解构默认值。locale 字典 owner 与不可变语言 token 是严格的语法级排除项。发现范围缩窄会直接失败,单元 fixture 固定纳入与排除形态,检查加入静态 CI 与 `hygiene` 图。字典 key 对等性仍由独立检查负责:一道门禁证明文案进入 locale 路径,另一道门禁证明两种发布语言都实现该路径。 + +[最初接入决策](2026-07-30-client-locale-full-rollout.zh.md)中的产品自产错误与设计字面量例外、原子组件默认文案和 trajectory 缓做均由本决定取代;其 label thunk、typed 席位、浏览器 locale、日期格式化和搜索占位行决定仍有效。 + +## Verification + +AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。 + +## Alternatives considered + +**只依赖评审与 AGENTS.md。** 否决。既有规则和 typed 字典与数百个绕过点同时存在;评审者需要在引入行收到源码级失败。 + +**使用文本正则,或禁止所有字符串字面量。** 否决。TypeScript 与 JSX 中包含 import、CSS class、判别值、事件名、SVG 数据和用户/wire 值。按语法上下文检查可在不扩张文件 allowlist 的情况下保持有效信号,而最小发现数量可防止扫描范围缩小后伪绿。 + +**为方便直接使用而保留原子组件回落文案。** 否决。回落值本身就是隐式 locale 选择。必填 label prop 让原子组件保持框架无关,并迫使每个产品渲染点明确文案 owner。 + +**翻译所有进入 DOM 的字符串。** 否决。外部编写的数据和协议/代码 token 并非产品措辞。翻译会破坏证据、标识符、命令、路径、URL 和提供方诊断;只有其周围的产品 chrome 属于 locale 系统。 + +## Consequences + +- 新增或修改 client UI 文案时,必须在两种 locale 中添加 typed 字典 key,并为受影响渲染路径提供行为证据。 +- 纯原子组件的显式 prop 类型变大,测试需提供有意选择的 label fixture;这项成本换来无隐藏 locale 行为。 +- AST 检查可以抓到产品编写的字面量绕过,却无法证明任意动态字符串 prop 已翻译。类型、字典对等性、组件测试和评审仍共同负责这一语义区分。 +- locale 服务之前渲染的 boot 标记和外部编写的运行时数据仍在字典路径之外;locale 激活后,产品 UI 会替换 boot 文案。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index e79f11cdc7..510e01f3d5 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -25,6 +25,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 4. **Registrations clean up.** Verify each new registry contribution passes the disposal tests required by [packages/AGENTS.md](../../../packages/AGENTS.md). 5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at the point where that package can observe it; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package invariant rules](../../../packages/AGENTS.md)). 6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect. +7. **Client UI copy is locale-owned.** Reject product text embedded in JSX, templates, helper returns, accessibility attributes, or primitive defaults. Require typed dictionary keys, the standard `t` seat or explicit localized props, `verify-client-ui-i18n`, and behavior evidence in each affected locale; preserve user/model/wire data and code tokens verbatim. ## Manual checks diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 9f9652415f..42f9bbab9e 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. - **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. -- **Prompts and visible strings:** treat wording as behavior. Update the owning runnable snapshot for model-visible text and the repository-required behavior evidence for GUI text. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. +- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. diff --git a/AGENTS.md b/AGENTS.md index 3eff4112d8..1928b25c31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,11 +122,12 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). -- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. +- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. - **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)). -- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). +- **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md index 1287e6e565..7852dffc5a 100644 --- a/apps/web/tests/snapshots/access-confirmation/ui.expected.md +++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md @@ -1,6 +1,6 @@ - dialog "确认启用 Full access?": - heading "确认启用 Full access?" [level=2] - - button "Close": + - button "关闭": - img - img - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 diff --git a/apps/web/tests/snapshots/search-card/grep-card.expected.txt b/apps/web/tests/snapshots/search-card/grep-card.expected.txt index 160251e62e..e67c6276d3 100644 --- a/apps/web/tests/snapshots/search-card/grep-card.expected.txt +++ b/apps/web/tests/snapshots/search-card/grep-card.expected.txt @@ -1,5 +1,5 @@ kind=matches -summary=显示 9 / 共 42 处匹配 · 3 个文件 +summary=Showing 9 of 42 matches · 3 files file=packages/client/ui-primitives/src/SearchBlock.tsx3 file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4 line=16: export const DEFAULT_SEARCH_MAX_LINES = 16 @@ -8,7 +8,7 @@ line=141: const [collapsed, setCollapsed] = useState>(() = line=36: const search = searchCardModel(block) line=56: search={search} line=78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow) -expand=… 其余 4 行 +expand=… 4 more lines recovery=Found 9 of 42 matches packages/client/ui-primitives/src/SearchBlock.tsx diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 9dba51c86c..1975b0b937 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -280,7 +280,7 @@ describe('web e2e: shipped default web search', () => { expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS) // The list is complete in the DOM, so the card carries no expand control. expect(await card.locator('button').count()).toBe(0) - expect(await card.getByText('来源列表已截断').isVisible()).toBe(true) + expect(await card.getByText('Source list truncated').isVisible()).toBe(true) const geometry = await sources.evaluate((element) => { const computed = getComputedStyle(element) diff --git a/package.json b/package.json index 54acae5cae..af7878df10 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", + "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 9baa35ebd0..0eb568afad 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -106,9 +106,11 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. -## Styling +## Styling and localization -[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English. +[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. + +Every product-visible string—including text, accessibility names, tooltips, placeholders, status/unit formatters, and primitive chrome—lives in a typed locale dictionary and reaches components through the standard `t` seat or an already-localized prop. Cordis-free primitives require complete label props and own no fallback copy. Keep user/model/wire data and code tokens verbatim; internal matching uses discriminants or stable ids, never localized text. `pnpm run verify-client-ui-i18n` enforces source ownership ([decision](../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). ## Testing and coverage @@ -145,6 +147,6 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com 1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. 3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. -4. Tokens only in CSS; Chinese product copy; English comments. +4. Tokens only in CSS; product copy follows the localization rule above; English comments. 5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 7f30563e17..096e4ab66a 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.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 packages/client/locale/README.md -README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9 -README.zh.md: 10fb3547376c8e960165a04fb4ea64ec8dd6f982 +README.md: 4f54a9a2c5aa9d39ee3c93a4d8f2c6deb538b792 +README.zh.md: d4c4833b2334c1b21f31e9b6f4d5116bbbb8591d diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 3fb5cce334..4f54a9a2c5 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience @@ -14,5 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly. - **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 10fb354737..d4c4833b23 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 +locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 ## 模型体验 @@ -14,5 +14,4 @@ locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存 ## 已知限制与暂缓事项 -- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。 - **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。 diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index b12965c6f5..05ac20dfe7 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -7,6 +7,13 @@ export const en = { 'close': 'Close', 'copy': 'Copy', 'copied': 'Copied', + 'copy.failed': 'Copy failed', + 'copy.value': 'Copy value', + 'copy.json': 'Copy JSON', + 'copy.path': 'Copy property path', + 'copy.prettyJson': 'Copy pretty JSON', + 'copy.compactJson': 'Copy compact JSON', + 'copy.optionsHint': '{action}; right-click for copy options', 'retry': 'Retry', 'loading': 'Loading…', 'load.failed': 'Failed to load', @@ -23,7 +30,14 @@ export const en = { 'collapse': 'Collapse', 'expand': 'Expand', 'back': 'Back', + 'brand.localBuild': 'DSH Local Build', 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', + 'connection.reconnecting': 'Connection lost; reconnecting…', + 'json.collapseNode': 'Collapse JSON node', + 'json.expandNode': 'Expand JSON node', + 'json.label': 'JSON', + 'markdown.footnotes': 'Footnotes', + 'markdown.truncatedCharacters': '… truncated at {total} characters', } satisfies Record diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 5bb62c4344..24c7e7e3ec 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -5,6 +5,13 @@ export const zh = { 'close': '关闭', 'copy': '复制', 'copied': '复制成功', + 'copy.failed': '复制失败', + 'copy.value': '复制值', + 'copy.json': '复制 JSON', + 'copy.path': '复制属性路径', + 'copy.prettyJson': '复制格式化 JSON', + 'copy.compactJson': '复制紧凑 JSON', + 'copy.optionsHint': '{action};右键点击可选择复制方式', 'retry': '重试', 'loading': '加载中…', 'load.failed': '加载失败', @@ -21,9 +28,16 @@ export const zh = { 'collapse': '收起', 'expand': '展开', 'back': '返回', + 'brand.localBuild': 'DSH 本地构建', 'unknown': '未知', 'none': '无', 'truncated': '已截断', + 'connection.reconnecting': '连接已断开,正在重连…', + 'json.collapseNode': '收起 JSON 节点', + 'json.expandNode': '展开 JSON 节点', + 'json.label': 'JSON', + 'markdown.footnotes': '脚注', + 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', } satisfies Record /** The common vocabulary key union (zh is the key-set source of truth). */ diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 1b91a60d46..9c294ee1f9 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -164,6 +164,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { description={confirmation.description} acknowledgeLabel={confirmation.acknowledgeLabel} cancelLabel={confirmation.cancelLabel} + closeLabel={t('close')} confirmLabel={confirmation.confirmLabel} acknowledged={state.acknowledged} onAcknowledgedChange={(value) => { popup.acknowledge(value) }} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index d47c8e12d0..9cf97fa22b 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,6 +4,7 @@ import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -26,7 +27,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. - const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) + const labels = useMemo(() => markdownLabels(t), [t]) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -46,7 +47,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ key={i} text={block.text} streaming={streaming} - codeLabels={codeLabels} + labels={labels} fileMentions={mentions} />, ) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 8964a32482..ff13ad7d8f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -129,7 +129,7 @@ function TurnStatus({ startTime, t }: { const showClock = elapsedMs >= 15_000 return (

- Deep diving... + {t('chat.deepDiving')} {showClock && ( {formatRunDuration(elapsedMs, t)} diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx index a4fd43723b..bf4305dc5b 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -15,7 +15,7 @@ export function CompactionCommandCard({ node, compaction, t }: CompactionCommand return ( diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index bcab4a360a..182aed69c0 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -1,7 +1,7 @@ // A compaction marker does not replace shadowed transcript rows. It is // expandable only when the current window includes its cited summary. -import { memo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client' import { IconApiOutline14, @@ -10,6 +10,7 @@ import { MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { markdownLabels } from '../markdown-labels.ts' import css from './MessageItem.module.css' interface CompactionItemProps { @@ -34,6 +35,7 @@ export const CompactionItem = memo(function CompactionItem({ t, }: CompactionItemProps) { const [expanded, setExpanded] = useState(false) + const labels = useMemo(() => markdownLabels(t), [t]) const expandable = node.summary !== null const open = expandable && expanded const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null @@ -68,7 +70,7 @@ export const CompactionItem = memo(function CompactionItem({ {summary} {open && node.summary !== null - &&
} + &&
}
) }) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 0f2f2dcbe3..24bd054225 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -96,7 +96,7 @@ function ModelRetryItem({ node, active, t }: {
{t('message.retry.delay')} - {Math.round(node.delayMs)}ms + {t('duration.milliseconds', { milliseconds: Math.round(node.delayMs) })}
{t('message.retry.failure')} diff --git a/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx b/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx index f8a340d20c..1f5fba56b8 100644 --- a/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx @@ -46,7 +46,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool titleClassName={css.title} chevronClassName={css.chevron} icon={} - title="Think" + title={t('message.think')} open={expanded} expandable expandOnRowClick diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 2d9d14483b..29af5b76c9 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -81,12 +81,12 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats { * @param n - token count. * @returns display string. */ -export function formatTokens(n: number): string { +export function formatTokens(n: number, t: ComposerBarProps['t']): string { const scaled = (v: number): string => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10) if (n < 1_000) return String(n) - if (n < 1_000_000) return `${scaled(n / 1_000)}K` - return `${scaled(n / 1_000_000)}M` + if (n < 1_000_000) return t('number.thousand', { value: scaled(n / 1_000) }) + return t('number.million', { value: scaled(n / 1_000_000) }) } /** @@ -94,11 +94,14 @@ export function formatTokens(n: number): string { * @param ms - duration in milliseconds. * @returns display string. */ -export function formatDuration(ms: number): string { +export function formatDuration(ms: number, t: ComposerBarProps['t']): string { const s = ms / 1_000 - if (s < 60) return `${Math.round(s * 10) / 10}s` + if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 }) const whole = Math.round(s) - return `${Math.floor(whole / 60)}m${whole % 60}s` + return t('duration.compactMinutes', { + minutes: Math.floor(whole / 60), + seconds: whole % 60, + }) } /** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */ @@ -222,12 +225,12 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t if (stats.steps > 0) { groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps })) const durations: string[] = [] - if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) })) - if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) })) + if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) })) + if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) })) if (durations.length > 0) groups.push(durations.join(' · ')) const speeds: string[] = [] if (stats.ttftSteps > 0) { - speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) })) + speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) })) } if (stats.decodeMs > 0) { speeds.push(t('stats.tokensPerSecond', { @@ -247,8 +250,8 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t const cacheHit = cacheHitPercent(usage) if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit })) groups.push(t('stats.tokens', { - input: formatTokens(billedInputTokens(usage)), - output: formatTokens(usage.outputTokens), + input: formatTokens(billedInputTokens(usage), t), + output: formatTokens(usage.outputTokens, t), })) } const line = groups.join(' | ') diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f4e7a7c59a..025b7b6083 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -55,6 +55,11 @@ export const zh = { 'context.system': '系统提示词', 'context.tools': '工具', 'context.messages': '对话消息', + 'number.thousand': '{value}K', + 'number.million': '{value}M', + 'duration.compactSeconds': '{seconds}秒', + 'duration.compactMinutes': '{minutes}分{seconds}秒', + 'duration.milliseconds': '{milliseconds}毫秒', 'stats.counts': '{turns} 轮 · {steps} 步', 'stats.llm': 'LLM {duration}', 'stats.toolCall': '工具调用 {duration}', @@ -71,6 +76,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', + 'access.fullLabel': 'Full access', 'hero.headline': '探索未至之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', @@ -92,6 +98,7 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', + 'chat.deepDiving': '正在深入处理…', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', @@ -116,6 +123,8 @@ export const zh = { 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', + 'message.compaction.commandTitle': 'compact', + 'message.think': '思考', 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', @@ -157,6 +166,46 @@ export const zh = { 'row.running': '运行中', 'row.failed': '失败', 'row.stopped': '已停止', + 'row.input': '输入', + 'row.output': '输出', + 'row.inspect': '查看', + 'tool.title.search': '搜索', + 'tool.title.read': '读取', + 'tool.title.bash': 'Bash', + 'tool.title.write': '写入', + 'tool.title.edit': '编辑', + 'tool.title.code': '代码', + 'tool.title.generic': '工具调用', + 'tool.title.inspect': '查看', + 'tool.title.runCordis': '运行 Cordis 插件', + 'tool.title.stopCordis': '停止 Cordis 插件', + 'tool.title.removeCordis': '移除 Cordis 插件', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'Glob', + 'tool.title.webSearch': '网页搜索', + 'tool.title.webFetch': '网页获取', + 'diff.files.one': '{count} 个文件', + 'diff.files.other': '{count} 个文件', + 'diff.collapseAria': '收起差异', + 'diff.expandAria': '展开其余 {count} 行差异', + 'diff.expandRest': '… 其余 {count} 行', + 'read.window': '显示 {shown} / {total} 行', + 'read.collapseAria': '收起内容', + 'read.expandAria': '展开其余 {count} 行', + 'read.expandRest': '… 其余 {count} 行', + 'search.paths': '{shown} 个路径', + 'search.paths.truncated': '显示 {shown} / 共 {total} 个路径', + 'search.matches': '{shown} 处匹配 · {files} 个文件', + 'search.matches.truncated': '显示 {shown} / 共 {total} 处匹配 · {files} 个文件', + 'search.noResults': '无结果', + 'search.collapseAria': '收起结果', + 'search.expandAria': '展开其余 {count} 行结果', + 'search.expandRest': '… 其余 {count} 行', + 'web.noResults': '未找到结果', + 'web.sourcesTruncated': '来源列表已截断', + 'web.http': 'HTTP', + 'web.contentTruncated': '内容已截断', 'queue.count': '{n} 条排队消息', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', @@ -232,6 +281,11 @@ export const en = { 'context.system': 'System prompt', 'context.tools': 'Tools', 'context.messages': 'Messages', + 'number.thousand': '{value}K', + 'number.million': '{value}M', + 'duration.compactSeconds': '{seconds}s', + 'duration.compactMinutes': '{minutes}m{seconds}s', + 'duration.milliseconds': '{milliseconds}ms', 'stats.counts': '{turns} turns · {steps} steps', 'stats.llm': 'LLM {duration}', 'stats.toolCall': 'Tool call {duration}', @@ -248,6 +302,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', + 'access.fullLabel': 'Full access', 'hero.headline': 'Into the Unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', @@ -269,6 +324,7 @@ export const en = { 'chat.loadError': 'Failed to load history: {message} ({code})', 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', + 'chat.deepDiving': 'Deep diving...', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'fileOpen.folderTitle': 'Couldn’t open folder', @@ -293,6 +349,8 @@ export const en = { 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', + 'message.compaction.commandTitle': 'compact', + 'message.think': 'Think', 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', @@ -334,6 +392,46 @@ export const en = { 'row.running': 'Running', 'row.failed': 'Failed', 'row.stopped': 'Stopped', + 'row.input': 'IN', + 'row.output': 'OUT', + 'row.inspect': 'Inspect', + 'tool.title.search': 'Search', + 'tool.title.read': 'Read', + 'tool.title.bash': 'Bash', + 'tool.title.write': 'Write', + 'tool.title.edit': 'Edit', + 'tool.title.code': 'Code', + 'tool.title.generic': 'Tool call', + 'tool.title.inspect': 'Inspect', + 'tool.title.runCordis': 'Run Cordis Plugin', + 'tool.title.stopCordis': 'Stop Cordis Plugin', + 'tool.title.removeCordis': 'Remove Cordis Plugin', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'Glob', + 'tool.title.webSearch': 'Search', + 'tool.title.webFetch': 'Fetch', + 'diff.files.one': '{count} file', + 'diff.files.other': '{count} files', + 'diff.collapseAria': 'Collapse diff', + 'diff.expandAria': 'Expand {count} more diff lines', + 'diff.expandRest': '… {count} more lines', + 'read.window': 'Showing {shown} of {total} lines', + 'read.collapseAria': 'Collapse content', + 'read.expandAria': 'Expand {count} more lines', + 'read.expandRest': '… {count} more lines', + 'search.paths': '{shown} paths', + 'search.paths.truncated': 'Showing {shown} of {total} paths', + 'search.matches': '{shown} matches · {files} files', + 'search.matches.truncated': 'Showing {shown} of {total} matches · {files} files', + 'search.noResults': 'No results', + 'search.collapseAria': 'Collapse results', + 'search.expandAria': 'Expand {count} more result lines', + 'search.expandRest': '… {count} more lines', + 'web.noResults': 'No results found', + 'web.sourcesTruncated': 'Source list truncated', + 'web.http': 'HTTP', + 'web.contentTruncated': 'Content truncated', 'queue.count': '{n} queued messages', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', diff --git a/packages/client/ui-conversation/src/client/markdown-labels.ts b/packages/client/ui-conversation/src/client/markdown-labels.ts new file mode 100644 index 0000000000..0f51d1fe6e --- /dev/null +++ b/packages/client/ui-conversation/src/client/markdown-labels.ts @@ -0,0 +1,26 @@ +/** Localized copy adapters for Cordis-free Markdown primitives. */ + +import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from './contract/slots.ts' + +/** + * Build the complete Markdown chrome copy for one locale revision. + * @param t - conversation locale seat. + * @returns labels for code fences and footnotes. + */ +export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { + return { + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + } +} + +/** + * Format the truncation footer for a JSON Markdown block. + * @param t - conversation locale seat. + * @param total - full serialized character count. + * @returns localized truncation footer. + */ +export function jsonTruncatedLabel(t: ChatViewSlotProps['t'], total: number): string { + return t('markdown.truncatedCharacters', { total }) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx index b6b62468d1..fbbc63b41b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx @@ -121,7 +121,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) { {reading} {headAfter} - {`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`} + {`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
@@ -141,7 +141,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) { {t(row.label)} -
{`~${formatTokens(breakdown[row.key])}`}
+
{`~${formatTokens(breakdown[row.key], t)}`}
))} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 1f72d993d6..249031f4c0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -57,8 +57,11 @@ function displayName(name: string): string { return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } -function optionLabel(option: PermissionSelectValue['options'][number]): string { - return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name) +function optionLabel( + option: PermissionSelectValue['options'][number], + t: ComposerBarProps['t'], +): string { + return option.value === FULL_ACCESS ? t('access.fullLabel') : displayName(option.name) } export interface PermissionSelectProps { @@ -92,7 +95,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect .filter(o => o.value !== 'custom') .map((option) => { const icon = permissionGlyph(option.value) - return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } } + return { id: option.value, label: optionLabel(option, t), ...icon === undefined ? {} : { icon } } }) const submit = (id: string): void => { @@ -138,7 +141,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect - {/* Failure copy stays English (error-surface policy: not localized). */} - {error !== null && failed to exit plan mode} + {error !== null && {t('chip.exitFailed')}} ) } diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 6410e6489b..b3048a7d5a 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -2,10 +2,12 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'chip.label': 'Plan', 'chip.on.aria': 'plan mode 已开启,按下关闭', 'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)', 'chip.off.aria': 'plan mode 已关闭,按下开启', 'chip.off.title': 'plan mode 已关闭 — 点击开启(/plan)', + 'chip.exitFailed': '退出 plan mode 失败', } satisfies Record /** The plan namespace key union. */ @@ -13,8 +15,10 @@ export type PlanKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'chip.label': 'Plan', 'chip.on.aria': 'Plan mode on, press to turn off', 'chip.on.title': 'Plan mode on — click to turn off (/plan off)', 'chip.off.aria': 'Plan mode off, press to turn on', 'chip.off.title': 'Plan mode off — click to turn on (/plan)', + 'chip.exitFailed': 'Failed to exit plan mode', } satisfies Record diff --git a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx index 04ec113ef3..70be459458 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx @@ -82,7 +82,7 @@ describe('PlanChip', () => { .mockRejectedValueOnce('socket closed') setup({ active: true, pending: false }, exitPlanMode) fireEvent.click(chip()) - expect((await screen.findByText('failed to exit plan mode')).getAttribute('title')).toBe('host said no') + expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') expect(chip()).toBeTruthy() fireEvent.click(chip()) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9bd21da46c..720136b270 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.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 packages/client/ui-primitives/README.md -README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81 -README.zh.md: 8de384f276e8be5b78d2659a1e14e4afe12c030e +README.md: c1c40e39710d46fae240f0b3281c36d660855010 +README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7822a5d41e..c1c40e3971 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -50,5 +50,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error. -- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing gets those defaults. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment. +- **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`, so `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionBanner`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` receive complete localized labels through props. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 8de384f276..631936ad65 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -50,5 +50,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。 -- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费方得到的就是这些默认值。`WebBlock` 尚未跟进这一模式:它的来源列表截断提示与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 +- **面向用户的文案必须由渲染点传入**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionBanner`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 和 `WebBlock` 都通过 prop 接收完整的本地化 label。本包不持有任何语言回落值;遗漏会导致类型检查失败,各功能把自己的 typed `t` 席位映射到原子组件 label 接口([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/ConnectionBanner.tsx b/packages/client/ui-primitives/src/ConnectionBanner.tsx index 5418e37159..1cb38e99b2 100644 --- a/packages/client/ui-primitives/src/ConnectionBanner.tsx +++ b/packages/client/ui-primitives/src/ConnectionBanner.tsx @@ -7,9 +7,9 @@ import css from './ConnectionBanner.module.css' * package is cordis-free, so copy arrives via props). * @returns the banner, or null when connected. */ -export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: { +export function ConnectionBanner({ reconnecting, label }: { reconnecting: boolean - label?: string | undefined + label: string }) { if (!reconnecting) return null return
{label}
diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index b1284243e6..4d97c72375 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -22,12 +22,25 @@ export interface DiffHunk { export interface DiffBlockProps { /** One entry per applied hunk, in file order; empty renders nothing. */ diffs: DiffHunk[] + /** Localized chrome supplied by the owning render site. */ + labels: DiffBlockLabels /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */ maxLines?: number | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined } +/** Localized chrome for {@link DiffBlock}. */ +export interface DiffBlockLabels { + copy: string + copied: string + collapseAria: string + expandAria: (hidden: number) => string + collapse: string + expand: (hidden: number) => string + files: (count: number) => string +} + /** A single rendered body line and its role, so the height cap slices a flat list. */ interface DiffRow { kind: 'path' | 'del' | 'add' | 'gap' @@ -123,7 +136,7 @@ function copyText(rows: DiffRow[]): string { * @param props - see {@link DiffBlockProps}. * @returns the diff block element. */ -export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { +export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs]) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) @@ -153,7 +166,7 @@ export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className return (
{head.map((row, index) => ( @@ -164,17 +177,17 @@ export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className type="button" className={css.expand} aria-expanded={expanded} - aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`} + aria-label={expanded ? labels.collapseAria : labels.expandAria(hidden)} onClick={onToggle} > - {expanded ? '收起' : `… 其余 ${hidden} 行`} + {expanded ? labels.collapse : labels.expand(hidden)} )} {tail.map((row, index) => (
{row.text}
))}
-
└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}
+
└ +{added} -{removed} · {labels.files(files)}
) } diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 56dbe46fce..216df4c5d5 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -14,21 +14,21 @@ import css from './HoverCard.module.css' * @param props.disabled - suppress opening; turning true closes an open card. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. - * @param props.copyLabel - accessible activation-label prefix (default "复制"). - * @param props.copiedLabel - visible success label (default "复制成功"). + * @param props.copyLabel - localized accessible activation-label prefix. + * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, - copyText, copyLabel = '复制', copiedLabel = '复制成功', + copyText, copyLabel, copiedLabel, }: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean copyText?: string | undefined - copyLabel?: string | undefined - copiedLabel?: string | undefined + copyLabel: string + copiedLabel: string }) { const rootRef = useRef(null) const cardRef = useRef(null) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 13905acde9..b9afb33960 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, @@ -17,9 +17,7 @@ const PREVIEW_DEPTH_LIMIT = 2 /** * Display copy for the tree's copy affordance; the owner passes localized - * labels (this package is cordis-free, so copy arrives via props). Every - * field defaults to the current built-in value, so existing consumers render - * unchanged. + * labels (this package is cordis-free, so copy arrives via props). */ export interface JsonTreeLabels { /** Menu item: copy the raw primitive value. */ @@ -44,19 +42,6 @@ export interface JsonTreeLabels { copyButtonTitle: (action: string) => string } -const DEFAULT_LABELS: JsonTreeLabels = { - copyValue: 'Copy value', - copyJson: 'Copy JSON', - copyPath: 'Copy property path', - copyPrettyJson: 'Copy pretty JSON', - copyCompactJson: 'Copy compact JSON', - copied: 'Copied', - copyFailed: 'Copy failed', - collapseNode: 'Collapse JSON node', - expandNode: 'Expand JSON node', - copyButtonTitle: action => `${action}; right-click for copy options`, -} - function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] { return [ { id: 'value', label: labels.copyValue }, @@ -388,15 +373,15 @@ export interface JsonTreeProps { /** Parsed JSON object or array. */ data: object | unknown[] /** Accessible label for the tree. */ - label?: string + label: string /** Optional positioning class owned by the caller. */ className?: string | undefined /** Whether JSON rows expose copy actions. */ copyable?: boolean /** Whether the top-level object or array is always expanded. */ expandTopLevel?: boolean - /** Localized display copy; omitted fields keep the built-in defaults. */ - labels?: Partial | undefined + /** Localized display copy supplied by the owning render site. */ + labels: JsonTreeLabels } /** @@ -406,16 +391,13 @@ export interface JsonTreeProps { */ export function JsonTree({ data, - label = 'JSON', + label, className, copyable = true, expandTopLevel = true, labels, }: JsonTreeProps) { - const copyLabels = useMemo( - () => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }), - [labels], - ) + const copyLabels = labels const rootEntries = entriesOf(data) const firstExpandableIndex = rootEntries.findIndex(([, value]) => ( isExpandableValue(value) && entriesOf(value).length > 0 diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 4686efa63a..37f3694501 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -20,7 +20,7 @@ import css from './Modal.module.css' * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, }: { open: boolean onClose: () => void @@ -60,7 +60,7 @@ export function Modal({

{title}

-
diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index 9aba555e10..eca2582978 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -29,6 +29,8 @@ export interface ReadBlockProps { label?: string | undefined /** The returned window's lines, in file order, each keeping its file line number. */ lines: readonly ReadBlockLine[] + /** Localized chrome supplied by the owning render site. */ + labels: ReadBlockLabels /** Exact total line count in the file, for the "showing N of M" note when the read is a window. */ totalLines: number /** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */ @@ -39,6 +41,17 @@ export interface ReadBlockProps { className?: string | undefined } +/** Localized chrome for {@link ReadBlock}. */ +export interface ReadBlockLabels { + window: (shown: number, total: number) => string + copy: string + copied: string + collapseAria: string + expandAria: (hidden: number) => string + collapse: string + expand: (hidden: number) => string +} + function renderSpans(spans: readonly HighlightSpan[]) { return spans.map((span, index) => {span.text}) } @@ -51,6 +64,7 @@ function renderSpans(spans: readonly HighlightSpan[]) { */ export function ReadBlock({ label, + labels, lines, totalLines, lang, @@ -104,13 +118,13 @@ export function ReadBlock({
{label ?? ''}
{windowed && ( - {`显示 ${lines.length} / ${totalLines} 行`} + {labels.window(lines.length, totalLines)} )} {lang ?? ''} {/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */} {lines.length > 0 && ( )}
@@ -122,10 +136,10 @@ export function ReadBlock({ type="button" className={css.expand} aria-expanded={expanded} - aria-label={expanded ? '收起内容' : `展开其余 ${hidden} 行`} + aria-label={expanded ? labels.collapseAria : labels.expandAria(hidden)} onClick={onToggle} > - {expanded ? '收起' : `… 其余 ${hidden} 行`} + {expanded ? labels.collapse : labels.expand(hidden)} )} {capped && rows(paired.slice(paired.length - tailLines))} diff --git a/packages/client/ui-primitives/src/RiskConfirmation.tsx b/packages/client/ui-primitives/src/RiskConfirmation.tsx index d9f8ebea76..8990cb8e5b 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.tsx +++ b/packages/client/ui-primitives/src/RiskConfirmation.tsx @@ -13,6 +13,7 @@ export interface RiskConfirmationProps { description: string acknowledgeLabel: string cancelLabel: string + closeLabel: string confirmLabel: string acknowledged: boolean disabled?: boolean @@ -31,6 +32,7 @@ export function RiskConfirmation({ description, acknowledgeLabel, cancelLabel, + closeLabel, confirmLabel, acknowledged, disabled = false, @@ -43,6 +45,7 @@ export function RiskConfirmation({ open={open} onClose={onCancel} title={title} + closeLabel={closeLabel} className={css.confirmation ?? ''} contentClassName={css.confirmationContent ?? ''} footer={( diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 63989455e3..c2328d6a9d 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -29,6 +29,8 @@ export interface SearchFileGroup { /** Fields both search shapes carry (the render site positions; this component draws). */ interface SearchBlockCommon { + /** Localized chrome supplied by the owning render site. */ + labels: SearchBlockLabels /** * Whether the tool capped the inline result: the shape carries only the * retained results, not every result the search found. The banner summary @@ -44,6 +46,19 @@ interface SearchBlockCommon { className?: string | undefined } +/** Localized chrome for {@link SearchBlock}. */ +export interface SearchBlockLabels { + pathsSummary: (shown: number, total: number, truncated: boolean) => string + matchesSummary: (shown: number, total: number, files: number, truncated: boolean) => string + copy: string + copied: string + noResults: string + collapseAria: string + expandAria: (hidden: number) => string + collapse: string + expand: (hidden: number) => string +} + /** Props for the grouped-matches (`grep`) shape. */ export interface SearchMatchesBlockProps extends SearchBlockCommon { kind: 'matches' @@ -113,10 +128,9 @@ function shownCount(props: SearchBlockProps): number { * @returns the summary text. */ function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string { - const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}` return props.kind === 'paths' - ? `${count} 个路径` - : `${count} 处匹配 · ${props.files.length} 个文件` + ? props.labels.pathsSummary(shown, total, truncated) + : props.labels.matchesSummary(shown, total, props.files.length, truncated) } /** @@ -232,12 +246,12 @@ export function SearchBlock(props: SearchBlockProps) { {summaryText(props, shown, truncated, total)} {!empty && ( )}
{empty - ?
无结果
+ ?
{props.labels.noResults}
: (
{head.map(row => ( @@ -248,10 +262,10 @@ export function SearchBlock(props: SearchBlockProps) { type="button" className={css.expand} aria-expanded={expanded} - aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`} + aria-label={expanded ? props.labels.collapseAria : props.labels.expandAria(hidden)} onClick={onToggle} > - {expanded ? '收起' : `… 其余 ${hidden} 行`} + {expanded ? props.labels.collapse : props.labels.expand(hidden)} )} {tailHeader !== undefined && ( diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index f23c78c139..26e26fe2d2 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -12,9 +12,7 @@ export const DEFAULT_TERMINAL_MAX_LINES = 16 /** * Display copy for the terminal surface; the owner passes localized labels - * (this package is cordis-free, so copy arrives via props). Every field - * defaults to the current built-in value, so existing consumers render - * unchanged. + * (this package is cordis-free, so copy arrives via props). */ export interface TerminalBlockLabels { /** Status pill text for a signal-terminated command. */ @@ -43,21 +41,6 @@ export interface TerminalBlockLabels { expand: (hidden: number) => string } -const DEFAULT_LABELS: TerminalBlockLabels = { - signal: signal => `信号 ${signal}`, - exitCode: exitCode => `退出码 ${exitCode}`, - running: '运行中', - failed: '失败', - done: '已完成', - copy: '复制', - copied: '复制成功', - noOutput: '无输出', - collapseAria: '收起输出', - collapse: '收起', - expandAria: hidden => `展开其余 ${hidden} 行输出`, - expand: hidden => `… 其余 ${hidden} 行`, -} - export interface TerminalBlockProps { /** The command line, rendered verbatim after the prompt label. */ command: string @@ -77,8 +60,8 @@ export interface TerminalBlockProps { maxLines?: number | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined - /** Localized display copy; omitted fields keep the built-in defaults. */ - labels?: Partial | undefined + /** Localized display copy supplied by the owning render site. */ + labels: TerminalBlockLabels } /** @@ -172,10 +155,7 @@ export function TerminalBlock({ className, labels, }: TerminalBlockProps) { - const copy = useMemo( - () => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }), - [labels], - ) + const copy = labels const text = output ?? '' // A command's output ends with a newline; that terminator is not an extra // blank line to draw or to count against the height cap. The check runs on the diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx index 5341d86cfe..c070151a88 100644 --- a/packages/client/ui-primitives/src/WebBlock.tsx +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { MarkdownText } from './markdown/MarkdownText.tsx' +import { MarkdownText, type MarkdownLabels } from './markdown/MarkdownText.tsx' import css from './WebBlock.module.css' /** @@ -21,6 +21,8 @@ export interface WebSourceView { /** A `web_search` card: an optional answer over a capped citation list. */ export interface WebSearchBlockProps { kind: 'search' + /** Localized chrome supplied by the owning render site. */ + labels: WebBlockLabels /** The provider-generated answer, rendered as markdown above the sources. */ answer?: string | undefined /** The cited sources, in provider order. */ @@ -34,6 +36,8 @@ export interface WebSearchBlockProps { /** A `web_fetch` card: the retrieval summary for one fetched URL. */ export interface WebFetchBlockProps { kind: 'fetch' + /** Localized chrome supplied by the owning render site. */ + labels: WebBlockLabels /** The final URL after allowed redirects; becomes a safe external link when http(s). */ url: string /** HTTP status code of the fetched response. */ @@ -47,6 +51,15 @@ export interface WebFetchBlockProps { /** A completed web retrieval card, discriminated by `kind`. */ export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps +/** Localized chrome for {@link WebBlock}. */ +export interface WebBlockLabels { + noResults: string + sourcesTruncated: string + http: string + contentTruncated: string + markdown: MarkdownLabels +} + /** * The URL to link to, or undefined when the URL must render as plain text. Only * http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:` @@ -132,7 +145,7 @@ function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: numbe * @param props - see {@link WebSearchBlockProps}. * @returns the search card element. */ -function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBlockProps) { +function WebSearchBlock({ answer, sources, truncated, labels, className }: WebSearchBlockProps) { // A provider may legitimately return no answer and no sources; the chat WebRow // does not show the raw result content, so without this the user would see an // empty card. Mirror the backend's `No results found.` render text. @@ -140,16 +153,16 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc return (
{answer !== undefined && answer !== '' && ( -
+
)} {empty ? ( -
未找到结果
+
{labels.noResults}
) : (
    {sources.map((source, index) => )}
)} - {truncated &&
来源列表已截断
} + {truncated &&
{labels.sourcesTruncated}
}
) } @@ -159,13 +172,13 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc * @param props - see {@link WebFetchBlockProps}. * @returns the fetch card element. */ -function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) { +function WebFetchBlock({ url, statusCode, truncated, labels, className }: WebFetchBlockProps) { return (
- HTTP {statusCode} - {truncated && 内容已截断} + {labels.http} {statusCode} + {truncated && {labels.contentTruncated}}
) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 619ce0a758..80734f08e9 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -34,20 +34,23 @@ export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx' -export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx' +export type { ReadBlockProps, ReadBlockLine, ReadBlockLabels } from './ReadBlock.tsx' export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' -export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' +export type { DiffBlockProps, DiffHunk, DiffBlockLabels } from './DiffBlock.tsx' export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' export type { SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, + SearchBlockLabels, } from './SearchBlock.tsx' export { WebBlock } from './WebBlock.tsx' -export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' +export type { + WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView, WebBlockLabels, +} from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' -export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index d79f2b47f9..cd109874cb 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -12,12 +12,12 @@ export interface CodeBlockProps { /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */ - copyLabel?: string | undefined + copyLabel: string /** Copy-button label during the post-copy confirmation window. */ - copiedLabel?: string | undefined + copiedLabel: string } -export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) { +export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code // Re-render when a lazy grammar finishes loading, so a fence that showed plain // text while its language's grammar imported picks up highlighting. The diff --git a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx index b63d633131..67794291d1 100644 --- a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx @@ -5,17 +5,12 @@ import css from './JsonBlock.module.css' const MAX_CHARS = 20_000 -/** Default truncation footer; the owner passes a localized formatter. */ -function defaultTruncatedLabel(total: number): string { - return `… 已截断,共 ${total} 字符` -} - -export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: { +export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel }: { label: string payload: unknown defaultOpen?: boolean /** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */ - truncatedLabel?: ((total: number) => string) | undefined + truncatedLabel: (total: number) => string }) { const [open, setOpen] = useState(defaultOpen) const body = useMemo(() => { diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 4dff78d784..b4fc2d4678 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -19,16 +19,16 @@ import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, wrapBlockChildren, } from './render.tsx' -import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx' +import type { MarkdownFileMentions, MarkdownLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './render.tsx' /** One settled full render: parse with math, resolve references, append the footnote section. */ function renderSettled( text: string, - codeLabels: MarkdownCodeLabels | undefined, + labels: MarkdownLabels, fileMentions: MarkdownFileMentions | undefined, ): ReactNode[] { const root = parseGfmWithMath(text) @@ -36,7 +36,7 @@ function renderSettled( collectReferenceTargets(root.children, targets) const context: MarkdownRenderContext = { streaming: false, - codeLabels, + labels, fileMentions, targets, footnoteOrder: [], @@ -67,8 +67,8 @@ class StreamingRenderer { private lastText: string | null = null private lastRendered: ReactNode[] = [] - /** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */ - constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {} + /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */ + constructor(private readonly labels: MarkdownLabels) {} /** * Render the current accumulated text. Idempotent per text value, so React @@ -100,7 +100,7 @@ class StreamingRenderer { if (newlyFrozen.length > 0) { const frozenContext: MarkdownRenderContext = { streaming: true, - codeLabels: this.codeLabels, + labels: this.labels, fileMentions: undefined, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, @@ -118,7 +118,7 @@ class StreamingRenderer { } const tailContext: MarkdownRenderContext = { streaming: true, - codeLabels: this.codeLabels, + labels: this.labels, fileMentions: undefined, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], @@ -141,8 +141,8 @@ class StreamingRenderer { * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; * `streaming` renders fences and TeX plain (highlighting and KaTeX land on - * the finalize swap) and parses incrementally across chunks; `codeLabels` - * forwards localized copy-button labels to fence CodeBlocks — pass a + * the finalize swap) and parses incrementally across chunks; `labels` + * forwards localized fence and footnote chrome — pass a * reference-stable object (memoized per locale revision), because a new * identity discards the streaming render cache mid-message. `fileMentions` * links inline-code tokens its resolver recognizes as real files; this is @@ -153,24 +153,24 @@ class StreamingRenderer { * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: { +export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions }: { text: string streaming?: boolean - codeLabels?: MarkdownCodeLabels | undefined + labels: MarkdownLabels fileMentions?: MarkdownFileMentions | undefined }) { const streamRef = useRef(null) - const streamLabelsRef = useRef(codeLabels) + const streamLabelsRef = useRef(labels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, codeLabels, fileMentions) + return renderSettled(text, labels, fileMentions) } - if (streamRef.current === null || streamLabelsRef.current !== codeLabels) { - streamRef.current = new StreamingRenderer(codeLabels) - streamLabelsRef.current = codeLabels + if (streamRef.current === null || streamLabelsRef.current !== labels) { + streamRef.current = new StreamingRenderer(labels) + streamLabelsRef.current = labels } return streamRef.current.render(text) - }, [text, streaming, codeLabels, fileMentions]) + }, [text, streaming, labels, fileMentions]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index de713858cf..55a9dadc42 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -30,9 +30,15 @@ import css from './MarkdownText.module.css' /** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */ export interface MarkdownCodeLabels { /** Copy-button idle label. */ - copyLabel?: string | undefined + copyLabel: string /** Copy-button label during the post-copy confirmation window. */ - copiedLabel?: string | undefined + copiedLabel: string +} + +/** Localized chrome for a Markdown document. */ +export interface MarkdownLabels { + code: MarkdownCodeLabels + footnotes: string } function sanitizeUrl(url: string): string { @@ -123,7 +129,7 @@ export interface MarkdownRenderContext { /** Streaming arm: fences render plain and TeX stays literal. */ readonly streaming: boolean /** Localized fence copy-button labels. */ - readonly codeLabels: MarkdownCodeLabels | undefined + readonly labels: MarkdownLabels /** Inside a blockquote's children: tables there always fill the quote's width. */ readonly inBlockquote?: boolean /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ @@ -329,8 +335,8 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re // that trim eat a REAL trailing blank line inside the fence instead. code={`${node.value}\n`} lang={context.streaming ? undefined : lang} - copyLabel={context.codeLabels?.copyLabel} - copiedLabel={context.codeLabels?.copiedLabel} + copyLabel={context.labels.code.copyLabel} + copiedLabel={context.labels.code.copiedLabel} /> ) } @@ -597,7 +603,7 @@ export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode if (items.length === 0) return null return (
-

Footnotes

+

{context.labels.footnotes}

    {items}
) diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 568853d0cb..1f23fc33ca 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -410,9 +410,9 @@ describe('Modal', () => { describe('ConnectionBanner', () => { it('renders only while reconnecting', () => { - const { container, rerender } = render() + const { container, rerender } = render() expect(container.firstChild).toBeNull() - rerender() - expect(container.textContent).toContain('重连') + rerender() + expect(container.textContent).toContain('Reconnecting') }) }) diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx index f1599c9bc5..62edc7c4a6 100644 --- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { CodeBlock } from '../src/markdown/CodeBlock.tsx' +import type { ComponentProps } from 'react' +import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx' import { highlightToHtml } from '../src/markdown/highlight.ts' +import { markdownLabels } from './labels.client.ts' + +function CodeBlock(props: Omit, 'copyLabel' | 'copiedLabel'>) { + return +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/diff-block.client.spec.tsx b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx index 21d2ec5744..aad3fecf5e 100644 --- a/packages/client/ui-primitives/tests/diff-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts' +import type { ComponentProps } from 'react' +import { DEFAULT_DIFF_MAX_LINES, DiffBlock as LocalizedDiffBlock, type DiffHunk } from '../src/index.ts' +import { diffBlockLabels } from './labels.client.ts' + +function DiffBlock(props: Omit, 'labels'>) { + return +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 53d5d2bcf7..5bb6f5786e 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -25,7 +25,13 @@ function mount(props: { copiedLabel?: string } = {}) { const view = render( - row} content={
card body
} {...props} />, + row} + content={
card body
} + copyLabel={props.copyLabel ?? 'Copy'} + copiedLabel={props.copiedLabel ?? 'Copied'} + {...props} + />, ) const anchor = screen.getByText('row') stubAnchorRect(anchor, { top: 40, right: 200 }) @@ -370,7 +376,15 @@ describe('HoverCard', () => { fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() - view.rerender(row} content={
card body
} disabled />) + view.rerender( + row} + content={
card body
} + copyLabel="Copy" + copiedLabel="Copied" + disabled + />, + ) expect(screen.queryByText('card body')).toBeNull() }) diff --git a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx index b9dd962ad3..691ba88652 100644 --- a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx @@ -2,7 +2,15 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { JsonTree } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ComponentProps } from 'react' +import { JsonTree as LocalizedJsonTree } from '@deepseek-ai/dsh-client-ui-primitives' +import { jsonTreeLabels } from './labels.client.ts' + +function JsonTree(props: Omit, 'label' | 'labels'> & { + label?: string +}) { + return +} let writeText: ReturnType diff --git a/packages/client/ui-primitives/tests/labels.client.ts b/packages/client/ui-primitives/tests/labels.client.ts new file mode 100644 index 0000000000..bb27e9dd34 --- /dev/null +++ b/packages/client/ui-primitives/tests/labels.client.ts @@ -0,0 +1,64 @@ +import type { + DiffBlockLabels, + JsonTreeLabels, + MarkdownLabels, + ReadBlockLabels, + SearchBlockLabels, + TerminalBlockLabels, + WebBlockLabels, +} from '../src/index.ts' + +export const markdownLabels: MarkdownLabels = { + code: { copyLabel: '复制', copiedLabel: '复制成功' }, + footnotes: 'Footnotes', +} + +export const diffBlockLabels: DiffBlockLabels = { + copy: '复制', copied: '复制成功', collapseAria: '收起差异', + expandAria: hidden => `展开其余 ${hidden} 行差异`, + collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`, + files: count => `${count} ${count === 1 ? 'file' : 'files'}`, +} + +export const readBlockLabels: ReadBlockLabels = { + window: (shown, total) => `显示 ${shown} / ${total} 行`, + copy: '复制', copied: '复制成功', collapseAria: '收起内容', + expandAria: hidden => `展开其余 ${hidden} 行`, + collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`, +} + +export const searchBlockLabels: SearchBlockLabels = { + pathsSummary: (shown, total, truncated) => truncated + ? `显示 ${shown} / 共 ${total} 个路径` + : `${shown} 个路径`, + matchesSummary: (shown, total, files, truncated) => truncated + ? `显示 ${shown} / 共 ${total} 处匹配 · ${files} 个文件` + : `${shown} 处匹配 · ${files} 个文件`, + copy: '复制', copied: '复制成功', noResults: '无结果', + collapseAria: '收起结果', + expandAria: hidden => `展开其余 ${hidden} 行结果`, + collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`, +} + +export const terminalBlockLabels: TerminalBlockLabels = { + signal: signal => `信号 ${signal}`, + exitCode: code => `退出码 ${code}`, + running: '运行中', failed: '失败', done: '已完成', + copy: '复制', copied: '复制成功', noOutput: '无输出', + collapseAria: '收起输出', collapse: '收起', + expandAria: hidden => `展开其余 ${hidden} 行输出`, + expand: hidden => `… 其余 ${hidden} 行`, +} + +export const jsonTreeLabels: JsonTreeLabels = { + copyValue: 'Copy value', copyJson: 'Copy JSON', copyPath: 'Copy property path', + copyPrettyJson: 'Copy pretty JSON', copyCompactJson: 'Copy compact JSON', + copied: 'Copied', copyFailed: 'Copy failed', + collapseNode: 'Collapse JSON node', expandNode: 'Expand JSON node', + copyButtonTitle: action => `${action}; right-click for copy options`, +} + +export const webBlockLabels: WebBlockLabels = { + noResults: '未找到结果', sourcesTruncated: '来源列表已截断', + http: 'HTTP', contentTruncated: '内容已截断', markdown: markdownLabels, +} diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx index c14e2eacc2..ec368bd666 100644 --- a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx @@ -3,7 +3,7 @@ // user-visible Markdown changes rather than regenerating them for refactors. import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText } from './markdown-test-components.tsx' afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx index 4397aea124..f3624b96d4 100644 --- a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx @@ -6,7 +6,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { Root, RootContent } from 'mdast' -import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText } from './markdown-test-components.tsx' import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts' import { parseGfm } from '../src/markdown/parse.ts' @@ -95,9 +95,13 @@ describe('incremental streaming rendering', () => { it('drops the streaming cache when the copy labels change identity', () => { const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n') - const live = render() + const live = render( + , + ) expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy']) - live.rerender() + live.rerender( + , + ) expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren']) live.unmount() }) diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx index dbad9ef164..9eb4eb0265 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx @@ -8,7 +8,8 @@ import { StrictMode } from 'react' import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type * as Md from 'mdast' -import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText } from './markdown-test-components.tsx' +import { markdownLabels } from './labels.client.ts' import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, } from '../src/markdown/render.tsx' @@ -19,7 +20,7 @@ afterEach(cleanup) function makeContext(): MarkdownRenderContext { return { streaming: false, - codeLabels: undefined, + labels: markdownLabels, fileMentions: undefined, targets: createReferenceTargets(), footnoteOrder: [], diff --git a/packages/client/ui-primitives/tests/markdown-test-components.tsx b/packages/client/ui-primitives/tests/markdown-test-components.tsx new file mode 100644 index 0000000000..77192d8e1c --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-test-components.tsx @@ -0,0 +1,37 @@ +import type { ComponentProps } from 'react' +import { + JsonBlock as LocalizedJsonBlock, + MarkdownText as LocalizedMarkdownText, + type MarkdownCodeLabels, + type MarkdownLabels, +} from '../src/index.ts' +import { markdownLabels as defaultMarkdownLabels } from './labels.client.ts' + +type MarkdownTextProps = Omit, 'labels'> & { + labels?: MarkdownLabels + codeLabels?: MarkdownCodeLabels +} + +export function MarkdownText({ + labels, + codeLabels, + ...props +}: MarkdownTextProps) { + const resolved = labels ?? (codeLabels === undefined + ? defaultMarkdownLabels + : { ...defaultMarkdownLabels, code: codeLabels }) + return +} + +type JsonBlockProps = Omit, 'truncatedLabel'> & { + truncatedLabel?: (total: number) => string +} + +export function JsonBlock({ truncatedLabel, ...props }: JsonBlockProps) { + return ( + `… 已截断,共 ${total} 字符`)} + /> + ) +} diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index 6b35712bd2..01e354ad81 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, MarkdownText } from './markdown-test-components.tsx' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' diff --git a/packages/client/ui-primitives/tests/read-block.client.spec.tsx b/packages/client/ui-primitives/tests/read-block.client.spec.tsx index 8982fb64aa..4ad939838d 100644 --- a/packages/client/ui-primitives/tests/read-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/read-block.client.spec.tsx @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts' +import type { ComponentProps } from 'react' +import { DEFAULT_READ_MAX_LINES, ReadBlock as LocalizedReadBlock, type ReadBlockLine } from '../src/index.ts' import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts' +import { readBlockLabels } from './labels.client.ts' + +function ReadBlock(props: Omit, 'labels'>) { + return +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/search-block.client.spec.tsx b/packages/client/ui-primitives/tests/search-block.client.spec.tsx index 1f925a45d3..462c334e20 100644 --- a/packages/client/ui-primitives/tests/search-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/search-block.client.spec.tsx @@ -2,8 +2,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts' -import type { SearchFileGroup } from '../src/index.ts' +import { DEFAULT_SEARCH_MAX_LINES, SearchBlock as LocalizedSearchBlock } from '../src/index.ts' +import type { + SearchFileGroup, SearchMatchesBlockProps, SearchPathsBlockProps, +} from '../src/index.ts' +import { searchBlockLabels } from './labels.client.ts' + +type SearchBlockProps = + | Omit + | Omit + +function SearchMatchesBlock(props: Omit) { + return +} + +function SearchPathsBlock(props: Omit) { + return +} + +function SearchBlock(props: SearchBlockProps) { + return props.kind === 'matches' + ? + : +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 15ae044881..45de33c8f0 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts' +import type { ComponentProps } from 'react' +import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock as LocalizedTerminalBlock } from '../src/index.ts' import { writeClipboard } from '../src/clipboard.ts' +import { terminalBlockLabels } from './labels.client.ts' + +function TerminalBlock(props: Omit, 'labels'>) { + return +} const ESC = '\u001b' diff --git a/packages/client/ui-primitives/tests/web-block.client.spec.tsx b/packages/client/ui-primitives/tests/web-block.client.spec.tsx index c595a7ef06..5248aeda5a 100644 --- a/packages/client/ui-primitives/tests/web-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/web-block.client.spec.tsx @@ -2,8 +2,29 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { WebBlock } from '../src/index.ts' -import type { WebSourceView } from '../src/index.ts' +import { WebBlock as LocalizedWebBlock } from '../src/index.ts' +import type { + WebFetchBlockProps, WebSearchBlockProps, WebSourceView, +} from '../src/index.ts' +import { webBlockLabels } from './labels.client.ts' + +type WebBlockProps = + | Omit + | Omit + +function WebSearchBlock(props: Omit) { + return +} + +function WebFetchBlock(props: Omit) { + return +} + +function WebBlock(props: WebBlockProps) { + return props.kind === 'search' + ? + : +} afterEach(cleanup) diff --git a/packages/client/ui-renderer/README.i18n.yaml b/packages/client/ui-renderer/README.i18n.yaml index a76bbe3a7f..4c0e05f393 100644 --- a/packages/client/ui-renderer/README.i18n.yaml +++ b/packages/client/ui-renderer/README.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 packages/client/ui-renderer/README.md -README.md: f63f1eff99d8d3357280f81648899bb68f5e2ef9 -README.zh.md: 1ff77298e7add13a0138604d5c8a0f86f6cd019f +README.md: 01ffc783eacc2588abca946d592ea72aa78fecd2 +README.zh.md: 3858330a08f23fd87a2f61620210e384ecc37ef4 diff --git a/packages/client/ui-renderer/README.md b/packages/client/ui-renderer/README.md index f63f1eff99..01ffc783ea 100644 --- a/packages/client/ui-renderer/README.md +++ b/packages/client/ui-renderer/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The browser Cordis plugin that owns the React rendering layer. [`dsh-client-web`](../web/README.md) renders a framework-free boot page and loads the complete client plugin roster; after every entry activates, it calls `ctx.uiRenderer.mount(container)`. This package provides that service, installs the slot renderer, hydrates the existing boot DOM, switches to the assembled application before the next paint, and returns the React root's unmount disposer. -The client entry also owns the React implementation of slot outlets, session providers, and observable-to-uSES binding. Business plugins pass bare observable sources through typed slot `hooks`; the renderer binds them at the outlet. The plugin activates after `slots`, `sessions`, and `layout`, projects the selected session title, and performs the sole context-level `renderSlot('root')` call. React, React DOM, Cordis, ui-slots, and ui-primitives retain one browser identity through the web shell's static module table; this package arrives as a dynamic client bundle. +The client entry also owns the React implementation of slot outlets, session providers, and observable-to-uSES binding. Business plugins pass bare observable sources through typed slot `hooks`; the renderer binds them at the outlet. The plugin activates after `slots`, `sessions`, and `locale`, projects the selected session title over the localized or build-configured product title, follows locale revisions, and performs the sole context-level `renderSlot('root')` call. React, React DOM, Cordis, ui-slots, and ui-primitives retain one browser identity through the web shell's static module table; this package arrives as a dynamic client bundle. ## Model Experience diff --git a/packages/client/ui-renderer/README.zh.md b/packages/client/ui-renderer/README.zh.md index 1ff77298e7..3858330a08 100644 --- a/packages/client/ui-renderer/README.zh.md +++ b/packages/client/ui-renderer/README.zh.md @@ -4,7 +4,7 @@ 负责 React 渲染层的浏览器 Cordis 插件。[`dsh-client-web`](../web/README.zh.md) 渲染不依赖框架的启动页并加载完整的客户端插件名册;所有 entry 激活后,它调用 `ctx.uiRenderer.mount(container)`。本包提供该服务、安装 slot 渲染器、hydrate 现有启动 DOM、在下一次绘制前切换到组装完成的应用,并返回 React 根的卸载 disposer。 -client entry 还持有 slot outlet、会话 provider 以及 observable 到 uSES 绑定的 React 实现。业务插件通过带类型的 slot `hooks` 传递裸 observable source;渲染器在 outlet 处完成绑定。插件在 `slots`、`sessions` 和 `layout` 就绪后激活,投影当前会话标题,并执行全程序唯一一次上下文级 `renderSlot('root')` 调用。React、React DOM、Cordis、ui-slots 和 ui-primitives 通过 web 外壳的静态模块表保持同一浏览器身份;本包则以动态客户端 bundle 到达。 +client entry 还持有 slot outlet、会话 provider 以及 observable 到 uSES 绑定的 React 实现。业务插件通过带类型的 slot `hooks` 传递裸 observable source;渲染器在 outlet 处完成绑定。插件在 `slots`、`sessions` 和 `locale` 就绪后激活,把当前会话标题投影到已本地化或由 build 配置的产品标题之上,跟随 locale revision,并执行全程序唯一一次上下文级 `renderSlot('root')` 调用。React、React DOM、Cordis、ui-slots 和 ui-primitives 通过 web 外壳的静态模块表保持同一浏览器身份;本包则以动态客户端 bundle 到达。 ## 模型体验 diff --git a/packages/client/ui-renderer/src/client/DocumentTitle.tsx b/packages/client/ui-renderer/src/client/DocumentTitle.tsx index a1111d99bd..4060a3b187 100644 --- a/packages/client/ui-renderer/src/client/DocumentTitle.tsx +++ b/packages/client/ui-renderer/src/client/DocumentTitle.tsx @@ -1,11 +1,11 @@ import { useEffect } from 'react' -const DEFAULT_CLIENT_TITLE = 'DSH Local Build' - /** Props for the browser title projection. */ export interface DocumentTitleProps { /** Durable title of the selected session, or undefined for the product title. */ title?: string + /** Build-configured or localized product title. */ + productTitle: string } /** @@ -14,8 +14,7 @@ export interface DocumentTitleProps { * @param props - Selected session title projection. * @returns No rendered content. */ -export function DocumentTitle({ title }: DocumentTitleProps): null { - const productTitle = process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE +export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null { useEffect(() => { document.title = title === undefined ? productTitle : `${title} — ${productTitle}` return () => { document.title = productTitle } diff --git a/packages/client/ui-renderer/src/client/app.tsx b/packages/client/ui-renderer/src/client/app.tsx index 446d184c5b..57546cb365 100644 --- a/packages/client/ui-renderer/src/client/app.tsx +++ b/packages/client/ui-renderer/src/client/app.tsx @@ -4,6 +4,7 @@ */ import type { ReactNode } from 'react' import type { Context } from '@deepseek-ai/cordis' +import type { LocaleFace } from '@deepseek-ai/dsh-client-ui-slots' import { bindSnapshotSelector } from './bind.ts' import { DocumentTitle } from './DocumentTitle.tsx' import type {} from '@deepseek-ai/dsh-client-runtime/client' @@ -23,13 +24,19 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { const { ctx } = deps const sessions = ctx.get('sessions') if (sessions === undefined) throw new Error('ui renderer: sessions service unavailable') + const locale = ctx.get('locale') as LocaleFace | undefined + if (locale === undefined) throw new Error('ui renderer: locale service unavailable') const useSessions = bindSnapshotSelector(sessions.list) + const useLocale = bindSnapshotSelector(locale) + const t = locale.bind('common') const SessionDocumentTitle = (): ReactNode => { + useLocale(snapshot => snapshot.revision) const title = useSessions((state) => { const id = state.current return id === undefined ? undefined : state.byId[id]?.title }) - return + const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild') + return } return () => ( <> diff --git a/packages/client/ui-renderer/src/client/index.ts b/packages/client/ui-renderer/src/client/index.ts index a7f45bd354..7979f2f0a0 100644 --- a/packages/client/ui-renderer/src/client/index.ts +++ b/packages/client/ui-renderer/src/client/index.ts @@ -38,7 +38,7 @@ declare module '@deepseek-ai/cordis' { } /** Services required before application assembly. */ -export const inject = ['slots', 'sessions'] +export const inject = ['slots', 'sessions', 'locale'] interface BootSnapshot { className: string diff --git a/packages/client/ui-renderer/tests/app.client.spec.tsx b/packages/client/ui-renderer/tests/app.client.spec.tsx index 0184198b77..6ec3c24754 100644 --- a/packages/client/ui-renderer/tests/app.client.spec.tsx +++ b/packages/client/ui-renderer/tests/app.client.spec.tsx @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { buildRenderApp } from '../src/client/app.tsx' +import { locale } from './locale.client.ts' let runtime: SlotTestRuntime | undefined @@ -18,6 +19,7 @@ afterEach(async () => { async function bench() { runtime = await SlotTestRuntime.create() + runtime.provide('locale', locale) await runtime.root.declare({}, () =>
) return { runtime, renderApp: buildRenderApp({ ctx: runtime.ctx }) } } diff --git a/packages/client/ui-renderer/tests/document-title.client.spec.tsx b/packages/client/ui-renderer/tests/document-title.client.spec.tsx index b0f8a3422c..2cec953cb1 100644 --- a/packages/client/ui-renderer/tests/document-title.client.spec.tsx +++ b/packages/client/ui-renderer/tests/document-title.client.spec.tsx @@ -13,13 +13,13 @@ describe('DocumentTitle', () => { it('projects a durable title and restores the product title', () => { vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness') document.title = 'stale title' - const mounted = render() + const mounted = render() expect(document.title).toBe('DeepSeek Harness') - mounted.rerender() + mounted.rerender() expect(document.title).toBe('First title — DeepSeek Harness') - mounted.rerender() + mounted.rerender() expect(document.title).toBe('Revised title — DeepSeek Harness') - mounted.rerender() + mounted.rerender() expect(document.title).toBe('DeepSeek Harness') mounted.unmount() expect(document.title).toBe('DeepSeek Harness') @@ -28,7 +28,7 @@ describe('DocumentTitle', () => { it('uses the generic title when the build provides no title', () => { vi.stubEnv('DSH_CLIENT_TITLE', '') delete process.env.DSH_CLIENT_TITLE - const mounted = render() + const mounted = render() expect(document.title).toBe('First title — DSH Local Build') mounted.unmount() expect(document.title).toBe('DSH Local Build') diff --git a/packages/client/ui-renderer/tests/locale.client.ts b/packages/client/ui-renderer/tests/locale.client.ts new file mode 100644 index 0000000000..66a149c637 --- /dev/null +++ b/packages/client/ui-renderer/tests/locale.client.ts @@ -0,0 +1,12 @@ +import type { LocaleFace } from '@deepseek-ai/dsh-client-ui-slots' + +/** Static locale face for renderer tests that do not exercise locale switching. */ +export const locale = { + bind: () => key => key === 'brand.localBuild' ? 'DSH Local Build' : key, + getSnapshot: () => ({ + active: 'en' as const, + locales: [{ id: 'zh' as const, label: '中文' }, { id: 'en' as const, label: 'English' }], + revision: 0, + }), + subscribe: () => () => {}, +} satisfies LocaleFace diff --git a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx index a966938468..14d24d6840 100644 --- a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx +++ b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx @@ -7,6 +7,7 @@ import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runti import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-renderer' import * as UiRenderer from '../src/client/index.ts' +import { locale } from './locale.client.ts' const mounted: (() => void)[] = [] @@ -25,6 +26,7 @@ async function bench() { const slots = ctx.get('slots') as SlotRegistry ctx.provide('sessions', new TestSessions(stabilize, ctx)) ctx.provide('workspaces', new TestWorkspaces(stabilize)) + ctx.provide('locale' as never, locale as never) const fiber = ctx.plugin({ inject: [...UiRenderer.inject], apply: UiRenderer.apply }) await fiber.await() return { ctx, slots, fiber } diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 5ccad5e4f7..0eb68f97f2 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.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 packages/client/ui-settings-models/README.md -README.md: 781d7a8134c8118ddf4a78f3ee153a8043bae0a6 -README.zh.md: 71c629dc6b0afd447e999d040328246677f75235 +README.md: cc430c91b9c4124fc70d0ec205b5870012dd5554 +README.zh.md: 62358adbc6f055697efe33e29e579f0aec64efc3 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 781d7a8134..cc430c91b9 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -6,7 +6,7 @@ Models settings and product-onboarding plugin. The same client Cordis plugin reg Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The notice step owns its exact copy and version in `src/onboarding-copy.ts`. On loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API; only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. +The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`. On loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API; only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. After that notice completes, the DeepSeek step projects first-run readiness from the same joined Models snapshot. ANY provider the user can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference and therefore authenticates natively. Only a user with none is asked for the official DeepSeek key. A mounted, active adapter with a missing writable reference renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. Configure later completes only this coordinator pass. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering; Models remains the diagnostic surface. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 71c629dc6b..62358adbc6 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -6,7 +6,7 @@ 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -声明步骤在 `src/onboarding-copy.ts` 中持有完整文案和版本。回环访问会通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`;只有明确点击「继续」才会记录当前版本。非回环浏览器无法使用这项仅限 Host 的 namespace,因此确认仅在当前进程有效,重载后声明会再次出现。 +声明步骤在 `src/client/locales.ts` 中持有完整文案,并在 `src/onboarding-copy.ts` 中持有确认版本。回环访问会通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`;只有明确点击「继续」才会记录当前版本。非回环浏览器无法使用这项仅限 Host 的 namespace,因此确认仅在当前进程有效,重载后声明会再次出现。 声明完成后,DeepSeek 步骤会从同一个 Models 联接快照得出首次运行就绪状态。只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名引用、因而走原生认证的路由。只有二者皆无的用户才会被要求填写 DeepSeek 官方密钥。适配器已挂载且活跃、引用可写但尚未配置时,既有 `ProviderEditor` 会以仅凭据模式渲染在共用引导弹窗中;`credentials.set` 仍是唯一的 secret 写入,且不会改变提供方设置。「稍后配置」只完成协调器当前这一轮。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤不渲染并直接完成;Models 页仍是诊断界面。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index f84118a2c6..836fc8efdc 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -226,7 +226,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { className={styles['input']} type="text" value={baseURL} - placeholder="https://gateway.example/v1" + placeholder={t('customBaseUrlPlaceholder')} aria-label={t('baseUrl')} disabled={profileDisabled} onChange={(event) => { setBaseURL(event.target.value) }} @@ -285,8 +285,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { t={t} busy={busy} submitDisabled={disabled || !ready} - submitLabel="create" - submitBusyLabel="creating" + submitLabelKey="create" + submitBusyLabelKey="creating" onCancel={() => { props.onClose(committed) }} onSubmit={() => { void create() }} /> diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 9e6cbb4c2a..024351087a 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -113,9 +113,9 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): credentialOnly credentialRequired autoFocusCredential - cancelLabel="onboardingLater" - submitLabel="onboardingSave" - submitBusyLabel="onboardingSaving" + cancelLabelKey="onboardingLater" + submitLabelKey="onboardingSave" + submitBusyLabelKey="onboardingSaving" onClose={finishCredential} />
diff --git a/packages/client/ui-settings-models/src/client/EditorFooter.tsx b/packages/client/ui-settings-models/src/client/EditorFooter.tsx index 6306609ca5..cca5ae5a7a 100644 --- a/packages/client/ui-settings-models/src/client/EditorFooter.tsx +++ b/packages/client/ui-settings-models/src/client/EditorFooter.tsx @@ -26,11 +26,11 @@ export interface EditorFooterProps { /** Whether the commit is refused, as judged by the owning card. */ submitDisabled: boolean /** Commit label while idle. */ - submitLabel: keyof typeof en + submitLabelKey: keyof typeof en /** Commit label while a commit is in flight. */ - submitBusyLabel: keyof typeof en + submitBusyLabelKey: keyof typeof en /** Dismiss label; defaults to the settings editor copy. */ - cancelLabel?: keyof typeof en + cancelLabelKey?: keyof typeof en /** Dismiss the card without committing. */ onCancel: () => void /** Run the card's commit. */ @@ -52,7 +52,7 @@ export function EditorFooter(props: EditorFooterProps): ReactNode { disabled={props.busy} onClick={props.onCancel} > - {t(props.cancelLabel ?? 'cancel')} + {t(props.cancelLabelKey ?? 'cancel')}
) diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 4cc6091b69..eb7a67cd03 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -76,11 +76,11 @@ export interface ProviderEditorProps { /** Give the credential field initial focus when this editor mounts. */ autoFocusCredential?: boolean /** Override the dismiss action copy. */ - cancelLabel?: keyof typeof en + cancelLabelKey?: keyof typeof en /** Override the idle commit action copy. */ - submitLabel?: keyof typeof en + submitLabelKey?: keyof typeof en /** Override the in-flight commit action copy. */ - submitBusyLabel?: keyof typeof en + submitBusyLabelKey?: keyof typeof en /** Close the editor; `changed` reports whether an Apply committed. */ onClose: (changed: boolean) => void } @@ -320,7 +320,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (node === undefined) { // A directory entry addressing a position its schema cannot resolve is a // host-side inconsistency; showing it beats a blank card. - return

{`${props.provider}: unresolvable settings path`}

+ return

{props.provider}: {props.t('settingsPathUnresolvable')}

} const keyLocked = keyState?.writable === false @@ -508,9 +508,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { || (props.credentialOnly !== true && modelFailure !== undefined) || shownKeyFailure !== undefined || (props.credentialRequired === true && keyValue.length === 0)} - submitLabel={props.submitLabel ?? 'apply'} - submitBusyLabel={props.submitBusyLabel ?? 'applying'} - {...props.cancelLabel === undefined ? {} : { cancelLabel: props.cancelLabel }} + submitLabelKey={props.submitLabelKey ?? 'apply'} + submitBusyLabelKey={props.submitBusyLabelKey ?? 'applying'} + {...props.cancelLabelKey === undefined ? {} : { cancelLabelKey: props.cancelLabelKey }} onCancel={() => { props.onClose(false) }} onSubmit={() => { void apply() }} /> diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 856ef64c7b..f1b0718ba5 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -1,7 +1,5 @@ /** Copy dictionaries for the Models settings section. */ -import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts' - /** English strings (the key-set source of truth for this pair). */ export const en = { nav: 'Models', @@ -87,11 +85,13 @@ export const en = { customApiUnset: 'Not selected', customNeedsBaseUrl: 'A custom provider needs a base URL.', customNeedsModels: 'A custom provider needs at least one model.', + customBaseUrlPlaceholder: 'https://gateway.example/v1', + settingsPathUnresolvable: 'unresolvable settings path', create: 'Create provider', creating: 'Creating\u2026', - welcomeTitle: WELCOME_NOTICE_COPY.en.title, - welcomeBody: WELCOME_NOTICE_COPY.en.body, - welcomeContinue: WELCOME_NOTICE_COPY.en.continueLabel, + welcomeTitle: 'Internal Testing Notice', + welcomeBody: "DeepSeek Harness 0.1 remains in testing for Harness developers. Many areas need further improvement, and we welcome feedback from the developer community. DeepSeek Harness's core plugins and foundational APIs will continue to evolve rapidly over the coming months.\n\nWe look forward to exploring the limits of intelligence with developers around the world, building on open-source, open, reusable, and composable infrastructure. We welcome Harness developers everywhere to join the DSH plugin ecosystem.", + welcomeContinue: 'Continue', welcomeError: 'The acknowledgement could not be saved. Please try again.', onboardingTitle: 'Add an API key to get started', onboardingDescription: 'Configure the official DeepSeek provider to start building.', @@ -189,11 +189,13 @@ export const zh: { [Key in keyof typeof en]: string } = { customApiUnset: '未选择', customNeedsBaseUrl: '自定义提供方需要填写 API 地址。', customNeedsModels: '自定义提供方至少需要一个模型。', + customBaseUrlPlaceholder: 'https://gateway.example/v1', + settingsPathUnresolvable: '无法解析设置路径', create: '创建提供方', creating: '创建中\u2026', - welcomeTitle: WELCOME_NOTICE_COPY.zh.title, - welcomeBody: WELCOME_NOTICE_COPY.zh.body, - welcomeContinue: WELCOME_NOTICE_COPY.zh.continueLabel, + welcomeTitle: '内测声明', + welcomeBody: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。', + welcomeContinue: '继续', welcomeError: '暂时无法保存确认状态,请重试。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-settings-models/src/onboarding-copy.ts b/packages/client/ui-settings-models/src/onboarding-copy.ts index d52371a9cf..8ee3277714 100644 --- a/packages/client/ui-settings-models/src/onboarding-copy.ts +++ b/packages/client/ui-settings-models/src/onboarding-copy.ts @@ -9,17 +9,3 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' * again. The acknowledgement is compared for exact equality. */ export const WELCOME_NOTICE_VERSION = '2026-08-13.1' - -/** The complete editable internal-testing notice in both supported GUI locales. */ -export const WELCOME_NOTICE_COPY = { - zh: { - title: '内测声明', - body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。', - continueLabel: '继续', - }, - en: { - title: 'Internal Testing Notice', - body: "DeepSeek Harness 0.1 remains in testing for Harness developers. Many areas need further improvement, and we welcome feedback from the developer community. DeepSeek Harness's core plugins and foundational APIs will continue to evolve rapidly over the coming months.\n\nWe look forward to exploring the limits of intelligence with developers around the world, building on open-source, open, reusable, and composable infrastructure. We welcome Harness developers everywhere to join the DSH plugin ecosystem.", - continueLabel: 'Continue', - }, -} as const diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 4948e12335..8a3b8fce0a 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -385,9 +385,9 @@ describe('ModelsSection', () => { credentialOnly credentialRequired autoFocusCredential - cancelLabel="onboardingLater" - submitLabel="onboardingSave" - submitBusyLabel="onboardingSaving" + cancelLabelKey="onboardingLater" + submitLabelKey="onboardingSave" + submitBusyLabelKey="onboardingSaving" onClose={onClose} />) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index bf2674976a..cad5d4d9fe 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -15,10 +15,15 @@ import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome- import type { WelcomeSection } from '../src/client/welcome-store.ts' import { en, zh } from '../src/client/locales.ts' import { - WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '../src/onboarding-copy.ts' +const WELCOME_NOTICE_COPY = { + en: { title: en.welcomeTitle, body: en.welcomeBody, continueLabel: en.welcomeContinue }, + zh: { title: zh.welcomeTitle, body: zh.welcomeBody, continueLabel: zh.welcomeContinue }, +} + afterEach(() => { cleanup() document.getElementById('root')?.remove() diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index a7ef74eaa6..d02518cb2d 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -143,7 +143,7 @@ export function SidebarRoot({ {renderSlot('sidebar.brand.name', {}, { fallback: ( <> - DSH Local Build + {t('brand.localBuild')} {process.env.DSH_CLIENT_COMMIT_HASH ? {process.env.DSH_CLIENT_COMMIT_HASH} : null} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index a8ef9f5d4e..ee59061984 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -266,7 +266,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo - DSH Local Build + DSH 本地构建 (en as Record)[key] ?? key +const t: SidebarRootComponentProps['t'] = key => + (en as Record)[key] ?? (commonEn as Record)[key] ?? key afterEach(() => { cleanup() diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx index dccea1bfc2..b5bceb9b3a 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx @@ -12,6 +12,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, waitFor } from '@testing-library/react' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' // The service reads its initial locale from the browser; these specs assert @@ -35,6 +37,7 @@ async function bench(options: { locale?: 'en' } = {}) { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { toggleSidebar: vi.fn() }) const locale = new LocaleRuntime(runtime.ctx) + locale.register('common', { zh: commonZh, en: commonEn }) if (options.locale === 'en') locale.setLocale('en') runtime.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index c6f9286154..bee776d93c 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -141,7 +141,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { > {leading} {status !== null ? {status} : null} - Skill + {t('row.title')} {summary} @@ -156,7 +156,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { {inspect !== undefined ? ( ) : null}
diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index 40ef78dea5..d7a9b94228 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -5,10 +5,12 @@ export const NS = 'skill' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'row.title': 'Skill', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'row.inspect': '查看', 'menu.userOnly': '仅用户', } satisfies Record @@ -17,9 +19,11 @@ export type SkillKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'row.title': 'Skill', 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'row.inspect': 'Inspect', 'menu.userOnly': 'user-only', } satisfies Record diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 2f8da20713..761107c1ff 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -125,17 +125,21 @@ describe('apply', () => { expect(presentation.dictionaries).toEqual([{ namespace: 'skill', dictionaries: { zh: { + 'row.title': 'Skill', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'row.inspect': '查看', 'menu.userOnly': '仅用户', }, en: { + 'row.title': 'Skill', 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'row.inspect': 'Inspect', 'menu.userOnly': 'user-only', }, }, diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index 8fbbaef7c0..e0556398ea 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -63,7 +63,7 @@ describe('SkillRow', () => { const card = screen.getByLabelText('说明') expect(card.textContent).toBe('说明Follow the issue workflow.\nKeep project fields in sync.') expect(view.container.textContent).not.toContain('{"name":"dsh-manage-issues"}') - fireEvent.click(screen.getByRole('button', { name: 'Inspect' })) + fireEvent.click(screen.getByRole('button', { name: '查看' })) expect(inspect).toHaveBeenCalledTimes(1) fireEvent.click(row) diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index e82d8adc44..90ffe6c4f1 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -63,13 +63,13 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { } /** Compact token count shared in shape with the conversation stats strip. */ -function formatTokens(value: number): string { +function formatTokens(value: number, t: TranslateNS): string { const scaled = (next: number): string => next >= 100 ? String(Math.round(next)) : String(Math.round(next * 10) / 10) if (value < 1_000) return String(value) - if (value < 1_000_000) return `${scaled(value / 1_000)}K` - return `${scaled(value / 1_000_000)}M` + if (value < 1_000_000) return t('tokens.thousand', { value: scaled(value / 1_000) }) + return t('tokens.million', { value: scaled(value / 1_000_000) }) } /** Sum the four disjoint durable provider-usage buckets. */ @@ -310,7 +310,7 @@ function CatalogRows({ ) const tokenMetric = totalTokens === undefined ? undefined - : `${formatTokens(totalTokens)} tok` + : t('tokens.total', { value: formatTokens(totalTokens, t) }) const durationMetric = durationMs === undefined ? undefined : { diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index b9c56ea001..312bad3af4 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -19,6 +19,9 @@ export const zh = { 'duration.yearsMonths': '约{years}年{months}个月', 'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒', 'duration.exactTitle': '总活跃耗时:{duration}', + 'tokens.thousand': '{value}K', + 'tokens.million': '{value}M', + 'tokens.total': '{value} tok', 'loading.label': '正在加载子代理…', 'loading.aria': '正在加载子代理', 'load.error': '无法加载子代理', @@ -57,6 +60,9 @@ export const en: Record = { 'duration.yearsMonths': '~{years}y {months}mo', 'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s', 'duration.exactTitle': 'Total active duration: {duration}', + 'tokens.thousand': '{value}K', + 'tokens.million': '{value}M', + 'tokens.total': '{value} tok', 'loading.label': 'Loading subagents…', 'loading.aria': 'Loading subagents', 'load.error': 'Unable to load subagents', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 0d511a1ceb..f31167fbde 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.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 packages/client/ui-tool/README.md -README.md: b87236309c9bafe3e35d3d5977d56bd62a24de31 -README.zh.md: 3d6a708f0af979e173ece9543d46d4ec18756f62 +README.md: 79b1bf27d848f015f132e38a635dd98c05a5dd87 +README.zh.md: 89469445b346dcb58a192a51cacdf9e66af50647 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index b87236309c..79b1bf27d8 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -46,4 +46,4 @@ None. The package is client-only presentation. - The Host excludes `run_code` from Code Mode program bindings, so production events produce one dispatch level; the recursive Runtime/UI contract supports nesting. - First-party Tool views are colocated here and can move to their owning business packages independently through the keyed slot. -- Tool copy reuses the `ui-conversation` locale namespace. +- Tool titles, row chrome, and every Cordis-free primitive label reuse the `ui-conversation` locale namespace; presenter models retain locale keys or data rather than rendered wording. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 3d6a708f0a..89469445b3 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -46,4 +46,4 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` - Host 不把 `run_code` 暴露为 Code Mode 程序 binding,因此生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。 - 第一方工具视图集中在本包,可以通过 keyed slot 独立迁移到各自所属的业务包。 -- 工具文案复用 `ui-conversation` locale namespace。 +- 工具标题、行 chrome 与每个 Cordis-free 原子组件 label 都复用 `ui-conversation` locale namespace;presenter 模型保留 locale key 或数据,而不保留渲染后的措辞。 diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx index fd3e509f1c..ec908af84a 100644 --- a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx @@ -5,6 +5,9 @@ import { diffCardModel } from './models/diff-card-model.ts' import { readCardModel } from './models/read-card-model.ts' import { searchCardModel } from './models/search-card-model.ts' import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts' +import { + diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, +} from './models/primitive-labels.ts' import { resultText } from './models/tool-call-model.ts' import { webCardModel } from './models/web-card-model.ts' import css from './ToolDetails.module.css' @@ -31,14 +34,14 @@ export function ToolDetails({ ) } const read = readCardModel(block, cwd, home) - if (read !== null) return + if (read !== null) return const diff = diffCardModel(block) - if (diff !== null) return + if (diff !== null) return const search = searchCardModel(block) if (search !== null) { return ( <> - + {search.recovery !== undefined ?
{search.recovery}
: null} ) @@ -48,7 +51,7 @@ export function ToolDetails({ const body = 'kind' in block ? resultText(block) : '' return ( <> - + {body !== '' ?
{body}
: null} ) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index c868666c68..30859bc911 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -3,13 +3,16 @@ import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts' import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts' +import { + diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, +} from '../models/primitive-labels.ts' import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' +import type { WebCardModelProps } from '../models/web-card-model.ts' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -39,7 +42,7 @@ export interface ToolRowProps { diff?: DiffCardModel | null | undefined read?: ReadCardModel | null | undefined search?: SearchCardModel | null | undefined - web?: WebBlockProps | null | undefined + web?: WebCardModelProps | null | undefined state: ToolRowState /** * Filesystem path from tool args; when set with onOpenFile, the summary @@ -181,13 +184,18 @@ export function ToolRow({ /> ) : diffBody !== null - ? + ? : readBody !== null - ? + ? : searchBody !== null ? ( <> - + {/* A capped search's recovery locator lives only in the result text; show it below the card so the dropped rows survive. */} {searchBody.recovery !== undefined && ( @@ -196,7 +204,7 @@ export function ToolRow({ ) : webBody !== null - ? + ? : ( <> {variant === 'code' && body !== null && ( @@ -208,7 +216,7 @@ export function ToolRow({
{cardBody !== null && (
- IN + {t('row.input')} {cardBody}
)} @@ -217,7 +225,7 @@ export function ToolRow({ )} {outputText !== null && (
- OUT + {t('row.output')} {outputText} @@ -234,7 +242,7 @@ export function ToolRow({ onClick={inspect} > - Inspect + {t('row.inspect')} )}
diff --git a/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts new file mode 100644 index 0000000000..4b48cf4f8e --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts @@ -0,0 +1,98 @@ +/** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */ + +import type { + DiffBlockLabels, + MarkdownLabels, + ReadBlockLabels, + SearchBlockLabels, + WebBlockLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' + +type T = TranslateNS<'conversation'> + +/** + * Build localized Markdown chrome labels. + * @param t - Conversation locale seat. + * @returns Markdown chrome labels. + */ +export function markdownLabels(t: T): MarkdownLabels { + return { + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + } +} + +/** + * Build localized diff-card chrome labels. + * @param t - Conversation locale seat. + * @returns Diff-card chrome labels. + */ +export function diffBlockLabels(t: T): DiffBlockLabels { + return { + copy: t('copy'), + copied: t('copied'), + collapseAria: t('diff.collapseAria'), + expandAria: count => t('diff.expandAria', { count }), + collapse: t('collapse'), + expand: count => t('diff.expandRest', { count }), + files: count => t(count === 1 ? 'diff.files.one' : 'diff.files.other', { count }), + } +} + +/** + * Build localized read-card chrome labels. + * @param t - Conversation locale seat. + * @returns Read-card chrome labels. + */ +export function readBlockLabels(t: T): ReadBlockLabels { + return { + window: (shown, total) => t('read.window', { shown, total }), + copy: t('copy'), + copied: t('copied'), + collapseAria: t('read.collapseAria'), + expandAria: count => t('read.expandAria', { count }), + collapse: t('collapse'), + expand: count => t('read.expandRest', { count }), + } +} + +/** + * Build localized search-card chrome labels. + * @param t - Conversation locale seat. + * @returns Search-card chrome labels. + */ +export function searchBlockLabels(t: T): SearchBlockLabels { + return { + pathsSummary: (shown, total, truncated) => t( + truncated ? 'search.paths.truncated' : 'search.paths', + { shown, total }, + ), + matchesSummary: (shown, total, files, truncated) => t( + truncated ? 'search.matches.truncated' : 'search.matches', + { shown, total, files }, + ), + copy: t('copy'), + copied: t('copied'), + noResults: t('search.noResults'), + collapseAria: t('search.collapseAria'), + expandAria: count => t('search.expandAria', { count }), + collapse: t('collapse'), + expand: count => t('search.expandRest', { count }), + } +} + +/** + * Build localized web-card chrome labels. + * @param t - Conversation locale seat. + * @returns Web-card chrome labels. + */ +export function webBlockLabels(t: T): WebBlockLabels { + return { + noResults: t('web.noResults'), + sourcesTruncated: t('web.sourcesTruncated'), + http: t('web.http'), + contentTruncated: t('web.contentTruncated'), + markdown: markdownLabels(t), + } +} diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts index 1c9393e6f4..4536833215 100644 --- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts @@ -32,7 +32,7 @@ import type { ToolCallBlock } from './tool-call-model.ts' type DistributiveOmit = T extends unknown ? Omit : never /** The {@link SearchBlockProps} union minus each render site's own fields. */ -type SearchBlockModelProps = DistributiveOmit +type SearchBlockModelProps = DistributiveOmit /** * Result rows the chat row's resident search body shows before collapsing the diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 02d3d56f30..8a8ab21785 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -11,6 +11,7 @@ // that produces the values). import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -20,11 +21,14 @@ export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'co /** Row state semantic; colors self-supplied via StateDot (design gives none). */ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' -/** Figma row titles per variant (design literals, not translatable copy). */ -export const VARIANT_TITLES: Record = { - search: 'Search', read: 'Read', bash: 'Bash', - write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call', -} +type ToolTitleKey = Extract, `tool.title.${string}`> + +/** Locale key per generic row variant. */ +export const VARIANT_TITLE_KEYS = { + search: 'tool.title.search', read: 'tool.title.read', bash: 'tool.title.bash', + write: 'tool.title.write', edit: 'tool.title.edit', code: 'tool.title.code', + others: 'tool.title.generic', +} as const satisfies Record /** * Known tool name -> variant. @@ -38,7 +42,7 @@ export const VARIANT_TITLES: Record = { const TOOL_VARIANTS: Record = { bash: 'bash', // The PowerShell twin is a shell tool: the bash row family (icon, colors) - // with its own title from TOOL_TITLES, not the generic `others` row. + // with its own title from TOOL_TITLE_KEYS, not the generic `others` row. pwsh: 'bash', read: 'read', web_fetch: 'read', @@ -60,13 +64,13 @@ const TOOL_VARIANTS: Record = { } /** Tool-owned titles that refine a generic row variant without replacing it. */ -const TOOL_TITLES: Record = { - cordis_package_inspect: 'Inspect', - cordis_runtime_inspect: 'Inspect', - cordis_run: 'Run Cordis Plugin', - cordis_stop: 'Stop Cordis Plugin', - cordis_undefine: 'Remove Cordis Plugin', - pwsh: 'Pwsh', +const TOOL_TITLE_KEYS: Record = { + cordis_package_inspect: 'tool.title.inspect', + cordis_runtime_inspect: 'tool.title.inspect', + cordis_run: 'tool.title.runCordis', + cordis_stop: 'tool.title.stopCordis', + cordis_undefine: 'tool.title.removeCordis', + pwsh: 'tool.title.pwsh', } /** @@ -81,7 +85,7 @@ export function classifyTool(toolName: string): ToolRowVariant { /** Everything ToolRow needs, derived once from the frozen slice. */ export interface ToolRowModel { variant: ToolRowVariant - title: string + titleKey: ToolTitleKey summary: string /** * Filesystem path from args (`path` / `file_path`) when the row is a file @@ -224,10 +228,10 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const base = argsRaw === '' ? block.callId : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home) - const toolTitle = TOOL_TITLES[toolName] + const toolTitleKey = TOOL_TITLE_KEYS[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. - const summary = variant === 'others' && toolName !== '' && toolTitle === undefined + const summary = variant === 'others' && toolName !== '' && toolTitleKey === undefined ? `${toolName} · ${base}` : base // The empty string is "no text" for both derived result fields: a settled @@ -237,7 +241,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const errorSummary = state === 'error' && output !== null ? firstLine(output) : null return { variant, - title: toolTitle ?? VARIANT_TITLES[variant], + titleKey: toolTitleKey ?? VARIANT_TITLE_KEYS[variant], summary, filePath: deriveFilePath(variant, argsRaw), body: deriveBody(variant, argsRaw), diff --git a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts index 270b387e62..8239861953 100644 --- a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts @@ -36,7 +36,17 @@ import type { ToolCallBlock } from './tool-call-model.ts' * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the web-card props, or null for the generic path. */ -export function webCardModel(block: ToolCallBlock): WebBlockProps | null { +type DistributiveOmit = T extends unknown ? Omit : never + +/** Web-card data owned by the presenter; render sites add localized labels and classes. */ +export type WebCardModelProps = DistributiveOmit + +/** + * Derive locale-independent web-card data from a frozen tool-call slice. + * @param block - Running or settled tool call from the conversation snapshot. + * @returns Web-card data, or null when the generic presenter owns the call. + */ +export function webCardModel(block: ToolCallBlock): WebCardModelProps | null { // Running calls have no result view; the web card is result-only. if (!('kind' in block)) return null const result = block.resultView diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index d3aa3ad7e1..32d7bb0dae 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -46,7 +46,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, variant={model.variant} toolName={toolName} icon={VARIANT_ICONS[model.variant]} - title={model.title} + title={t(model.titleKey)} summary={terminal?.description ?? search?.title ?? model.summary} // Single-file tools never expose an args body — the path link is the only // args interaction. A card is not an args body: a read/write/edit row is diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index f476e0b075..a408110581 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -89,7 +89,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: > {leading} {status !== null && {status}} - {model.title} + {t(model.titleKey)} {failureLine ?? terminal?.description ?? model.summary} @@ -110,7 +110,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
{model.body !== null && (
- IN + {t('row.input')} {model.body}
)} @@ -119,7 +119,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )} {model.output !== null && (
- OUT + {t('row.output')} {model.output} @@ -130,7 +130,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: {inspect !== undefined && ( )}
diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index ecd16b52c7..551bcd8e30 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -21,7 +21,7 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, variant={model.variant} toolName={toolName} icon={} - title={model.title} + title={t(model.titleKey)} summary={model.summary} body={null} output={model.output} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx index 375950c823..df3060c4e5 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx @@ -21,7 +21,7 @@ export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: Re variant={model.variant} toolName={toolName} icon={} - title={model.title} + title={t(model.titleKey)} summary={model.summary} body={null} output={model.output} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 80f59b886f..dfd33251de 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -9,10 +9,10 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'> -const SEARCH_TITLES: Record = { - grep: 'Grep', - glob: 'Glob', -} +const SEARCH_TITLE_KEYS = { + grep: 'tool.title.grep', + glob: 'tool.title.glob', +} as const /** Lets users expand grep or glob results and recover capped searches. */ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { @@ -24,7 +24,9 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { variant={model.variant} toolName={toolName} icon={} - title={SEARCH_TITLES[toolName] ?? model.title} + title={t(toolName === 'grep' + ? SEARCH_TITLE_KEYS.grep + : toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)} summary={search?.title ?? model.summary} body={null} // ToolRow ignores output when a structured card is present; otherwise it diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index 4be87b10d5..89cc20df83 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -9,10 +9,10 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' type WebRowProps = ToolCallViewProps & PropsLocale<'conversation'> -const WEB_TITLES: Record = { - web_search: 'Search', - web_fetch: 'Fetch', -} +const WEB_TITLE_KEYS = { + web_search: 'tool.title.webSearch', + web_fetch: 'tool.title.webFetch', +} as const /** Lets users expand a completed web search or fetch result. */ export function WebRow({ toolName, block, inspect, t }: WebRowProps) { @@ -25,7 +25,9 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) { variant={model.variant} toolName={toolName} icon={icon} - title={WEB_TITLES[toolName] ?? model.title} + title={t(toolName === 'web_search' + ? WEB_TITLE_KEYS.web_search + : toolName === 'web_fetch' ? WEB_TITLE_KEYS.web_fetch : model.titleKey)} summary={model.summary} body={null} output={model.output} diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index e4cf03c044..60aa0e9880 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -199,7 +199,7 @@ describe('FileMutationRow diff card', () => { }), 'write')} />) // The footer counts live inside the collapsed diff card. toggleRow(view) - expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy() + expect(view.getByText('└ +1 -0 · 1 个文件')).toBeTruthy() }) it('reflects the run state on its leading slot', () => { diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 400b8eecdd..4333cf93d4 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -193,7 +193,7 @@ describe('ReadRow keyed toolview', () => { it('collapses to the path summary; the whole row toggles the read card', () => { const view = render() - expect(view.getByText('Read')).toBeTruthy() + expect(view.getByText('读取')).toBeTruthy() // Collapsed: the path is the summary link alone, and the card is absent. expect(view.getAllByText('src/a.ts').length).toBe(1) expect(view.container.querySelector('[data-read]')).toBeNull() diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 4d6eeeacd8..b6fef1c6e7 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -430,8 +430,8 @@ describe('BashRow terminal card', () => { fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') - expect(view.getByText('IN')).toBeTruthy() - expect(view.getByText('OUT')).toBeTruthy() + expect(view.getByText('输入')).toBeTruthy() + expect(view.getByText('输出')).toBeTruthy() expect(view.getByText(/"command": "ls -la"/)).toBeTruthy() expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted') }) diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index d311453c3d..aca4c04283 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -51,9 +51,9 @@ describe('tool-call-model', () => { // Every define/run pair the model makes puts a row in the flow, so the // generic "Tool call · cordis_run · dyn-1" fallback is user-visible slop. const titleOf = (name: string) => toolRowModel(name, running({ name, argsRaw: '{"id":"dyn-1"}' })) - expect(titleOf('cordis_run').title).toBe('Run Cordis Plugin') - expect(titleOf('cordis_stop').title).toBe('Stop Cordis Plugin') - expect(titleOf('cordis_undefine').title).toBe('Remove Cordis Plugin') + expect(t(titleOf('cordis_run').titleKey)).toBe('运行 Cordis 插件') + expect(t(titleOf('cordis_stop').titleKey)).toBe('停止 Cordis 插件') + expect(t(titleOf('cordis_undefine').titleKey)).toBe('移除 Cordis 插件') // An owned title takes the tool name out of the summary slot, leaving the // package id as the only mutable text. expect(titleOf('cordis_run').summary).toBe('dyn-1') @@ -66,21 +66,21 @@ describe('tool-call-model', () => { // title here would be a second answer to what the card already renders. const model = toolRowModel('cordis_define', running({ name: 'cordis_define', argsRaw: '{"name":"clock"}' })) expect(model.variant).toBe('others') - expect(model.title).toBe('Tool call') + expect(t(model.titleKey)).toBe('工具调用') }) it('renders cordis mount verbs no shipped tool implements as generic calls', () => { // No shipped tool implements these cordis mount verbs, so a mapping would // be unreachable. expect(classifyTool('cordis_mount')).toBe('others') - expect(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).title).toBe('Tool call') - expect(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).title).toBe('Tool call') + expect(t(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).titleKey)).toBe('工具调用') + expect(t(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).titleKey)).toBe('工具调用') }) it('gives the pwsh shell row the bash family treatment with its own title', () => { const m = toolRowModel('pwsh', running()) expect(m.variant).toBe('bash') - expect(m.title).toBe('Pwsh') + expect(t(m.titleKey)).toBe('Pwsh') }) it('derives state across running/ok/error/interrupted', () => { @@ -92,7 +92,7 @@ describe('tool-call-model', () => { it('derives the bash summary from description over command', () => { const m = toolRowModel('bash', running()) - expect(m.title).toBe('Bash') + expect(t(m.titleKey)).toBe('Bash') expect(m.summary).toBe('List files') expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd') }) @@ -203,7 +203,7 @@ describe('tool-call-model', () => { argsRaw: '{"what":"api","name":"tools"}', }))).toMatchObject({ variant: 'read', - title: 'Inspect', + titleKey: 'tool.title.inspect', summary: 'api', }) expect(toolRowModel('cordis_run', running({ @@ -211,14 +211,14 @@ describe('tool-call-model', () => { argsRaw: '{"id":"dyn-2"}', }))).toMatchObject({ variant: 'others', - title: 'Run Cordis Plugin', + titleKey: 'tool.title.runCordis', summary: 'dyn-2', }) expect(toolRowModel('cordis_undefine', result({ call: { name: 'cordis_undefine', argsRaw: '{"id":"dyn-2"}' }, }))).toMatchObject({ variant: 'others', - title: 'Remove Cordis Plugin', + titleKey: 'tool.title.removeCordis', summary: 'dyn-2', }) }) @@ -364,9 +364,9 @@ describe('ToolRow', () => { const inspect = vi.fn() const view = render() // Collapsed: no pill. - expect(view.queryByText('Inspect')).toBeNull() + expect(view.queryByText('查看')).toBeNull() fireEvent.click(view.getByRole('button', { name: /Bash/ })) - const pill = view.getByText('Inspect') + const pill = view.getByText('查看') fireEvent.click(pill) expect(inspect).toHaveBeenCalledTimes(1) // The pill click must not collapse the row (body is a .row sibling). @@ -376,25 +376,25 @@ describe('ToolRow', () => { it('no inspect callback, no pill', () => { const view = render() fireEvent.click(view.getByRole('button')) - expect(view.queryByText('Inspect')).toBeNull() + expect(view.queryByText('查看')).toBeNull() }) it('the expanded card gutter-labels each section it carries (IN / OUT)', () => { const both = render() fireEvent.click(both.getByRole('button')) - expect(both.getByText('IN')).toBeTruthy() - expect(both.getByText('OUT')).toBeTruthy() + expect(both.getByText('输入')).toBeTruthy() + expect(both.getByText('输出')).toBeTruthy() expect(both.getByText('result text')).toBeTruthy() cleanup() const inputOnly = render() fireEvent.click(inputOnly.getByRole('button')) - expect(inputOnly.getByText('IN')).toBeTruthy() - expect(inputOnly.queryByText('OUT')).toBeNull() + expect(inputOnly.getByText('输入')).toBeTruthy() + expect(inputOnly.queryByText('输出')).toBeNull() cleanup() const outputOnly = render() fireEvent.click(outputOnly.getByRole('button')) - expect(outputOnly.queryByText('IN')).toBeNull() - expect(outputOnly.getByText('OUT')).toBeTruthy() + expect(outputOnly.queryByText('输入')).toBeNull() + expect(outputOnly.getByText('输出')).toBeTruthy() expect(outputOnly.getByText('only out')).toBeTruthy() }) }) @@ -415,7 +415,7 @@ describe('GenericToolCard', () => { const view = render( , ) - expect(view.getByText('Tool call')).toBeTruthy() + expect(view.getByText('工具调用')).toBeTruthy() expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() }) @@ -427,7 +427,7 @@ describe('GenericToolCard', () => { argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}', }))} />, ) - expect(view.getByText('Edit')).toBeTruthy() + expect(view.getByText('编辑')).toBeTruthy() expect(view.getByText('src/x.ts')).toBeTruthy() expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull() expect(view.container.querySelector('svg')).not.toBeNull() @@ -440,7 +440,7 @@ describe('GenericToolCard', () => { argsRaw: '{"file_path":"src/x.ts","content":"hello"}', }))} />, ) - expect(view.getByText('Write')).toBeTruthy() + expect(view.getByText('写入')).toBeTruthy() expect(view.getByText('src/x.ts')).toBeTruthy() expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull() expect(view.container.querySelector('svg')).not.toBeNull() @@ -450,7 +450,7 @@ describe('GenericToolCard', () => { const inspect = vi.fn() const view = render() fireEvent.click(view.getByRole('button', { name: /Bash/ })) - fireEvent.click(view.getByText('Inspect')) + fireEvent.click(view.getByText('查看')) expect(inspect).toHaveBeenCalledTimes(1) }) diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f9712c091e..1c87e66660 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -135,7 +135,7 @@ describe('chat row web body', () => { const globe = render().container.querySelector('svg')!.outerHTML const view = render() // Collapsed: the summary row alone, no card in the DOM. - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('网页搜索')).toBeTruthy() expect(view.container.querySelector('svg')?.outerHTML).toBe(globe) expect(view.queryByText('Titled')).toBeNull() expect(view.container.querySelector('[data-web]')).toBeNull() @@ -149,7 +149,7 @@ describe('chat row web body', () => { it('the WebRow expands to the fetch card, titled Fetch', () => { const view = render() - expect(view.getByText('Fetch')).toBeTruthy() + expect(view.getByText('网页获取')).toBeTruthy() expect(view.container.querySelector('[data-web]')).toBeNull() toggleRow(view) // The url shows as the card's link; scope to the card. @@ -160,7 +160,7 @@ describe('chat row web body', () => { it('a running web call is the summary row alone, with nothing to expand', () => { const view = render() - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('网页搜索')).toBeTruthy() expect(view.queryByText('Titled')).toBeNull() // No card material and no expandable body: clicking the row reveals nothing. expect(view.container.querySelector('[data-expandable]')).toBeNull() @@ -171,7 +171,7 @@ describe('chat row web body', () => { const view = render() - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('网页搜索')).toBeTruthy() expect(view.container.querySelector('[data-web]')).toBeNull() // The row reflects the error state so the summary line still reads as failed. expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index a2344e22ba..47b35ae17d 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.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 packages/client/ui-trajectory/README.md -README.md: d3053076068a6c78997d570eb321cff27bc53eed -README.zh.md: 9d7718c0d7d6ef87ad0d84df80838029cf20dbc4 +README.md: 97badd562bbf132c9766c763ff604306d1a6c08d +README.zh.md: 5a17ee811047e8ffd15be849595d87adfe4ddf00 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index d305307606..97badd562b 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 9d7718c0d7..5a17ee8110 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index 51ec2a0ec3..a25b09833e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -5,6 +5,7 @@ import { type TrajectoryCellKind, type TrajectoryCellProps, } from './trajectory-record.ts' +import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' import css from './TrajectoryCell.module.css' export { formatElapsedSeconds } @@ -15,14 +16,14 @@ export type { } from './trajectory-record.ts' /** Display label per kind (matches the design tags). */ -const KIND_LABEL: Record = { - system: 'System', - user: 'User', - context: 'Context', - compacted: 'Compacted', - message: 'Message', - tool: 'Tool', - subtool: 'Sub', +const KIND_LABEL_KEY: Record = { + system: 'kind.system', + user: 'kind.user', + context: 'kind.context', + compacted: 'kind.compacted', + message: 'kind.message', + tool: 'kind.tool', + subtool: 'kind.sub', } const TAG_CLASS: Record = { @@ -41,6 +42,7 @@ const TAG_CLASS: Record = { * @returns the cell element. */ export function TrajectoryCell({ + t, index, kind, text, @@ -64,7 +66,7 @@ export function TrajectoryCell({ selected = false, className, ...rest -}: TrajectoryCellProps) { +}: TrajectoryCellProps & { t: TrajectoryTranslate }) { const rootClass = [ css.root, selected ? css.selected : undefined, @@ -75,7 +77,7 @@ export function TrajectoryCell({
#{index} - c !== undefined).join(' ')}>{KIND_LABEL[kind]} + c !== undefined).join(' ')}>{t(KIND_LABEL_KEY[kind])} {text} @@ -86,7 +88,7 @@ export function TrajectoryCell({ {think ?? ''} ) : null} - {formatElapsedSeconds(timeSeconds)} + {formatElapsedSeconds(timeSeconds, t)}
) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index dab72e6778..8777c83265 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -12,6 +12,7 @@ import { MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' import type { AssistantRequestConfig, ConversationPromptSnapshot, @@ -26,6 +27,8 @@ import { import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts' import type { TrajectoryTurnModel } from './layout.ts' import { trajectoryPreviewText } from './trajectory-preview.ts' +import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' +import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -35,14 +38,14 @@ const VIRTUALIZATION_THRESHOLD = 100 const VIRTUAL_OVERSCAN_ROWS = 12 const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600 -const KIND_LABEL: Record = { - system: 'SYSTEM', - user: 'USER', - context: 'CONTEXT', - compacted: 'COMPACTED', - message: 'ASSISTANT', - tool: 'TOOL', - subtool: 'SUBTOOL', +const KIND_LABEL_KEY: Record = { + system: 'kind.system', + user: 'kind.user', + context: 'kind.context', + compacted: 'kind.compacted', + message: 'kind.assistant', + tool: 'kind.tool', + subtool: 'kind.subtool', } function ToolWrenchIcon(): ReactNode { @@ -170,7 +173,7 @@ type RecordState = 'complete' | 'running' | 'error' interface DetailTabItem { id: DetailTab - label: string + labelKey: TrajectoryKey } interface ParentRecords { @@ -207,20 +210,42 @@ const TOOL_REQUEST_MAX_WIDTH = 480 const DEFAULT_TOOL_REQUEST_SHARE = 0.36 const DEFAULT_TOOL_REQUEST_OFFSET = 56 const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [ - { id: 'system-prompt', label: 'System Prompt' }, - { id: 'tools', label: 'Tools' }, + { id: 'system-prompt', labelKey: 'tab.systemPrompt' }, + { id: 'tools', labelKey: 'tab.tools' }, ] const SYSTEM_UPDATE_TABS: readonly DetailTabItem[] = [ - { id: 'diff', label: 'Diff' }, + { id: 'diff', labelKey: 'tab.diff' }, ...SYSTEM_PROMPT_TABS, ] const REQUEST_TABS: readonly DetailTabItem[] = [ - { id: 'overview', label: 'Summary' }, - { id: 'options', label: 'Options' }, - { id: 'usage', label: 'Usage' }, - { id: 'timing', label: 'Timing' }, + { id: 'overview', labelKey: 'tab.summary' }, + { id: 'options', labelKey: 'tab.options' }, + { id: 'usage', labelKey: 'tab.usage' }, + { id: 'timing', labelKey: 'tab.timing' }, ] +function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels { + return { + copyValue: t('copy.value'), + copyJson: t('copy.json'), + copyPath: t('copy.path'), + copyPrettyJson: t('copy.prettyJson'), + copyCompactJson: t('copy.compactJson'), + copied: t('copied'), + copyFailed: t('copy.failed'), + collapseNode: t('json.collapseNode'), + expandNode: t('json.expandNode'), + copyButtonTitle: action => t('copy.optionsHint', { action }), + } +} + +function markdownLabels(t: TrajectoryTranslate): MarkdownLabels { + return { + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + } +} + type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string } @@ -257,13 +282,15 @@ function defaultToolRequestWidth(splitWidth: number): number { ) } -function formatDurationMs(milliseconds: number): string { - if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms` - return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s` +function formatDurationMs(milliseconds: number, t: TrajectoryTranslate): string { + if (milliseconds < 1_000) return t('unit.milliseconds', { value: Math.round(milliseconds) }) + return t('unit.seconds', { + value: (milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1), + }) } -function formatStartedAt(timestamp: number | null): string { - if (timestamp === null || !Number.isFinite(timestamp)) return 'Not available' +function formatStartedAt(timestamp: number | null, t: TrajectoryTranslate): string { + if (timestamp === null || !Number.isFinite(timestamp)) return t('timing.notAvailable') const date = new Date(timestamp) const two = (value: number) => String(value).padStart(2, '0') const three = (value: number) => String(value).padStart(3, '0') @@ -281,70 +308,77 @@ function clickSelectsText(target: Node): boolean { && selection.getRangeAt(0).intersectsNode(target) } -function StartedAtValue({ timestamp }: { timestamp: number | null }) { +function StartedAtValue({ timestamp, t }: { timestamp: number | null; t: TrajectoryTranslate }) { const [showUnix, setShowUnix] = useState(false) - if (timestamp === null || !Number.isFinite(timestamp)) return
Not available
+ if (timestamp === null || !Number.isFinite(timestamp)) return
{t('timing.notAvailable')}
return (
) } -function totalTime(metrics: AssistantMetricDetail): string { - if (!metrics.timingRecorded) return 'Not recorded' - if (metrics.stepStartTime === null) return 'Step start unavailable' - if (metrics.completedTime === null) return 'Pending' - return formatDurationMs(Math.max(0, metrics.completedTime - metrics.stepStartTime)) +function totalTime(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.timingRecorded) return t('timing.notRecorded') + if (metrics.stepStartTime === null) return t('timing.stepStartUnavailable') + if (metrics.completedTime === null) return t('status.pending') + return formatDurationMs(Math.max(0, metrics.completedTime - metrics.stepStartTime), t) } -function ttft(metrics: AssistantMetricDetail): string { - if (!metrics.timingRecorded) return 'Not recorded' - if (metrics.stepStartTime === null) return 'Step start unavailable' - if (metrics.firstTokenTime === null) return 'First token unavailable' - return formatDurationMs(Math.max(0, metrics.firstTokenTime - metrics.stepStartTime)) +function ttft(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.timingRecorded) return t('timing.notRecorded') + if (metrics.stepStartTime === null) return t('timing.stepStartUnavailable') + if (metrics.firstTokenTime === null) return t('timing.firstTokenUnavailable') + return formatDurationMs(Math.max(0, metrics.firstTokenTime - metrics.stepStartTime), t) } -function generationTime(metrics: AssistantMetricDetail): string { - if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable' - if (metrics.completedTime === null) return 'Pending' - return formatDurationMs(Math.max(0, metrics.completedTime - metrics.firstTokenTime)) +function generationTime(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.timingRecorded || metrics.firstTokenTime === null) return t('timing.firstTokenUnavailable') + if (metrics.completedTime === null) return t('status.pending') + return formatDurationMs(Math.max(0, metrics.completedTime - metrics.firstTokenTime), t) } -function throughput(metrics: AssistantMetricDetail): string { - if (!metrics.usageProvided) return 'Usage unavailable' - if (metrics.outputTokens === null) return 'Output tokens unavailable' - if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable' - if (metrics.completedTime === null) return 'Pending' +function throughput(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.usageProvided) return t('timing.usageUnavailable') + if (metrics.outputTokens === null) return t('timing.outputTokensUnavailable') + if (!metrics.timingRecorded || metrics.firstTokenTime === null) return t('timing.firstTokenUnavailable') + if (metrics.completedTime === null) return t('status.pending') const generationSeconds = (metrics.completedTime - metrics.firstTokenTime) / 1_000 - if (generationSeconds <= 0) return 'Duration too short' - return `${(metrics.outputTokens / generationSeconds).toFixed(1)} tok/s` + if (generationSeconds <= 0) return t('timing.durationTooShort') + return t('unit.tokensPerSecond', { + value: (metrics.outputTokens / generationSeconds).toFixed(1), + }) } -function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) { +function AssistantTimingPanel({ + metrics, + t, +}: { metrics: AssistantMetricDetail; t: TrajectoryTranslate }) { return (
-
Started
-
Total duration
{totalTime(metrics)}
-
TTFT
{ttft(metrics)}
-
Generation
{generationTime(metrics)}
-
Throughput
{throughput(metrics)}
+
{t('timing.started')}
+
{t('timing.totalDuration')}
{totalTime(metrics, t)}
+
{t('timing.ttft')}
{ttft(metrics, t)}
+
{t('timing.generation')}
{generationTime(metrics, t)}
+
{t('timing.throughput')}
{throughput(metrics, t)}
) } /** Props for the trajectory ledger. */ export interface TrajectoryTableProps { + /** Trajectory locale seat. */ + t: TrajectoryTranslate /** Session-global request numbers for the request groups visible in this context. */ requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ @@ -485,57 +519,43 @@ function filterRecords( return filtered } -function requestStep(group: string): number | undefined { - if (!group.startsWith('Step ')) return undefined - const value = Number(group.slice('Step '.length)) - return Number.isInteger(value) && value > 0 ? value : undefined -} - function requestKey(turn: number | null, group: string): string { return `${turn}\u0000${group}` } -function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap { +function indexRequestBoundaries( + records: readonly TableRecord[], + requestGroups: ReadonlySet, +): ReadonlyMap { const boundaries = new Map() for (const record of records) { const key = requestKey(record.turn, record.group) + if (!requestGroups.has(key)) continue if (boundaries.has(key)) continue - if (requestStep(record.group) === undefined) { - if (record.groupStart) boundaries.set(key, record.cell.index) - continue - } if (record.cell.kind === 'user' || record.cell.kind === 'context') continue boundaries.set(key, record.cell.index) } return boundaries } -function sectionLabel(turn: number | null): string { - return turn === null ? 'Between turns' : `Turn ${turn}` +function sectionLabel(turn: number | null, t: TrajectoryTranslate): string { + return turn === null ? t('section.betweenTurns') : t('turn.label', { turn }) } function indexRequestNumbers( - records: readonly TableRecord[], sessionNumbers: readonly TrajectoryRequestNumber[] | undefined, - boundaries: ReadonlyMap, ): ReadonlyMap { const numbers = new Map() for (const request of sessionNumbers ?? []) { numbers.set(requestKey(request.turn, request.group), request.number) } - let next = Math.max(0, ...numbers.values()) + 1 - const boundaryRecords = records - .filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index - && requestStep(record.group) !== undefined) - .sort((left, right) => left.cell.index - right.cell.index) - for (const record of boundaryRecords) { - const key = requestKey(record.turn, record.group) - if (!numbers.has(key)) numbers.set(key, next++) - } return numbers } -function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { +function indexRequestBoundaryRuns( + records: readonly TableRecord[], + requestGroups: ReadonlySet, +): ReadonlyMap { const indexes = new Map() let runLength = 0 for (const record of records) { @@ -543,7 +563,11 @@ function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap< indexes.set(record.cell.index, runLength++) continue } - if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) { + if ( + runLength > 0 + && record.groupStart + && requestGroups.has(requestKey(record.turn, record.group)) + ) { indexes.set(record.cell.index, runLength) } runLength = 0 @@ -551,24 +575,32 @@ function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap< return indexes } -function summarizeTurn(records: readonly TableRecord[]): string { +function summarizeTurn( + records: readonly TableRecord[], + requestGroups: ReadonlySet, + t: TrajectoryTranslate, +): string { const steps = new Set( records - .map(record => record.group) - .filter(group => group.startsWith('Step ')), + .map(record => requestKey(record.turn, record.group)) + .filter(key => requestGroups.has(key)), ).size const toolCalls = records.filter(record => record.cell.kind === 'tool' || record.cell.kind === 'subtool', ).length return [ - `${steps} ${steps === 1 ? 'step' : 'steps'}`, - `${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`, + t(steps === 1 ? 'summary.steps.one' : 'summary.steps.other', { count: steps }), + t(toolCalls === 1 ? 'summary.toolCalls.one' : 'summary.toolCalls.other', { + count: toolCalls, + }), ].join(' · ') } function collapseTurnRecords( records: readonly TableRecord[], collapsedTurns: ReadonlySet, + requestGroups: ReadonlySet, + t: TrajectoryTranslate, ): TableRecord[] { const recordsByTurn = new Map() for (const record of records) { @@ -592,7 +624,7 @@ function collapseTurnRecords( groupStart: false, turnStart: false, turnEnd: true, - collapsedSummary: summarizeTurn(contentRecords.slice(1)), + collapsedSummary: summarizeTurn(contentRecords.slice(1), requestGroups, t), collapsedSummaryKind: 'turn', }, ] @@ -674,32 +706,32 @@ function stateOf(record: TableRecord): RecordState { return 'complete' } -function statusLabel(state: RecordState): string { - if (state === 'error') return 'Failed' - if (state === 'running') return 'Pending' - return 'Completed' +function statusLabel(state: RecordState, t: TrajectoryTranslate): string { + if (state === 'error') return t('status.failed') + if (state === 'running') return t('status.pending') + return t('status.completed') } -function TokenRows({ cell }: { cell: TrajectoryCellProps }) { +function TokenRows({ cell, t }: { cell: TrajectoryCellProps; t: TrajectoryTranslate }) { const content = cell.output !== undefined && cell.think !== undefined ? Math.max(0, cell.output - cell.think) : undefined return ( <>
-
Tokens
-
{cell.output === undefined ? '—' : `${cell.output} tok`}
+
{t('usage.tokens')}
+
{cell.output === undefined ? '—' : t('unit.tokens', { value: cell.output })}
{cell.think !== undefined && (
-
Reasoning
-
{cell.think} tok
+
{t('usage.reasoning')}
+
{t('unit.tokens', { value: cell.think })}
)} {content !== undefined && (
-
Content
-
{content} tok
+
{t('usage.content')}
+
{t('unit.tokens', { value: content })}
)} @@ -715,8 +747,8 @@ function inputTotal(usage: TrajectoryUsage): number | undefined { return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) } -function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { - if (usage === undefined) return

Usage not reported

+function UsageRows({ usage, t }: { usage: TrajectoryUsage | undefined; t: TrajectoryTranslate }) { + if (usage === undefined) return

{t('usage.notReported')}

const totalInput = inputTotal(usage) const otherOutput = usage.output !== undefined && usage.reasoning !== undefined ? usage.output - usage.reasoning @@ -724,39 +756,39 @@ function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { return (
{totalInput !== undefined && ( -
Input
{totalInput} tok
+
{t('usage.input')}
{t('unit.tokens', { value: totalInput })}
)} {usage.cacheRead !== undefined && (
-
Cached
-
{usage.cacheRead} tok
+
{t('usage.cached')}
+
{t('unit.tokens', { value: usage.cacheRead })}
)} {usage.cacheWrite !== undefined && (
-
Cache created
-
{usage.cacheWrite} tok
+
{t('usage.cacheCreated')}
+
{t('unit.tokens', { value: usage.cacheWrite })}
)} {usage.input !== undefined && (
-
Other
-
{usage.input} tok
+
{t('usage.other')}
+
{t('unit.tokens', { value: usage.input })}
)} {usage.output !== undefined && ( -
Output
{usage.output} tok
+
{t('usage.output')}
{t('unit.tokens', { value: usage.output })}
)} {usage.reasoning !== undefined && (
-
Reasoning
-
{usage.reasoning} tok
+
{t('usage.reasoning')}
+
{t('unit.tokens', { value: usage.reasoning })}
)} {otherOutput !== undefined && (
-
Content
-
{otherOutput} tok
+
{t('usage.content')}
+
{t('unit.tokens', { value: otherOutput })}
)}
@@ -766,19 +798,21 @@ function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { function RequestUsagePanel({ usage, cumulative, + t, }: { usage: TrajectoryUsage | undefined cumulative: TrajectoryUsage | undefined + t: TrajectoryTranslate }) { return (
-

This request

- +

{t('usage.thisRequest')}

+
-

Session cumulative

- +

{t('usage.sessionCumulative')}

+
) @@ -787,55 +821,59 @@ function RequestUsagePanel({ function RequestOptions({ options, preview = false, + t, }: { options: AssistantRequestConfig | undefined preview?: boolean + t: TrajectoryTranslate }) { if (options === undefined) { - return

Options not recorded

+ return

{t('options.notRecorded')}

} return ( ) } -function messageSourceLabel(source: unknown): string { +function messageSourceLabel(source: unknown, t: TrajectoryTranslate): string { if (typeof source !== 'object' || source === null || Array.isArray(source)) { - return 'Unknown' + return t('source.unknown') } const properties = source as Record const kind = properties.kind - if (kind === 'user') return 'User' + if (kind === 'user') return t('source.user') if (kind === 'plugin') { const plugin = properties.plugin return typeof plugin === 'string' && plugin !== '' - ? `Plugin · ${plugin}` - : 'Plugin' + ? t('source.pluginNamed', { plugin }) + : t('source.plugin') } if (kind === 'goal') { const round = properties.round return typeof round === 'number' && round > 0 - ? `Goal · Round ${round}` - : 'Goal' + ? t('source.goalRound', { round }) + : t('source.goal') } - if (typeof kind !== 'string' || kind === '') return 'Unknown' + if (typeof kind !== 'string' || kind === '') return t('source.unknown') return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}` } -function MessageSource({ record }: { record: TableRecord }) { +function MessageSource({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) { const source = record.cell.messageSource - if (source === undefined) return

Source not recorded

+ if (source === undefined) return

{t('source.notRecorded')}

const data = typeof source === 'object' && source !== null ? source : { value: source } return ( ) @@ -899,31 +937,31 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { } if (record.cell.kind === 'compacted') { return [ - { id: 'overview', label: 'Summary' }, - { id: 'raw', label: 'Raw Output' }, + { id: 'overview', labelKey: 'tab.summary' }, + { id: 'raw', labelKey: 'tab.rawOutput' }, ] } if (isMarkdownRecord(record)) { return [ - { id: 'overview', label: 'Summary' }, - { id: 'rendered', label: 'Preview' }, - { id: 'raw', label: 'Raw' }, + { id: 'overview', labelKey: 'tab.summary' }, + { id: 'rendered', labelKey: 'tab.preview' }, + { id: 'raw', labelKey: 'tab.raw' }, ...(record.cell.messageSource === undefined ? [] - : [{ id: 'source', label: 'Source' } as const]), + : [{ id: 'source', labelKey: 'tab.source' } as const]), ] } return [ - { id: 'overview', label: 'Summary' }, - ...(record.cell.inputDetail ? [{ id: 'input', label: 'Payload' } as const] : []), - ...(record.cell.outputDetail ? [{ id: 'output', label: 'Result' } as const] : []), - { id: 'schema', label: 'Schema' }, - { id: 'timing', label: 'Timing' }, + { id: 'overview', labelKey: 'tab.summary' }, + ...(record.cell.inputDetail ? [{ id: 'input', labelKey: 'tab.payload' } as const] : []), + ...(record.cell.outputDetail ? [{ id: 'output', labelKey: 'tab.result' } as const] : []), + { id: 'schema', labelKey: 'tab.schema' }, + { id: 'timing', labelKey: 'tab.timing' }, ] } -function recordDisplayText(cell: TrajectoryCellProps): string { - if (isToolCallOnly(cell)) return '' +function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string { + if (isToolCallOnly(cell, t)) return '' if (cell.previewMarkdown !== undefined) { const preview = trajectoryPreviewText(cell.previewMarkdown) if (cell.text === '') return preview @@ -957,11 +995,11 @@ function toolCallTextParts( } } -function isToolCallOnly(cell: TrajectoryCellProps): boolean { +function isToolCallOnly(cell: TrajectoryCellProps, t: TrajectoryTranslate): boolean { return cell.kind === 'message' && !cell.outputDetail && !cell.thinkingDetail - && cell.text === 'Tool call only' + && cell.text === t('layout.toolCallOnly') } interface RecordPresentationValue { @@ -975,25 +1013,27 @@ interface RecordPresentationValue { function RecordPresentation({ cell, children, + t, }: { cell: TrajectoryCellProps children: (value: RecordPresentationValue) => ReactNode + t: TrajectoryTranslate }) { const displayText = useMemo( - () => recordDisplayText(cell), + () => recordDisplayText(cell, t), [ cell.kind, cell.text, cell.previewMarkdown, - cell.inputDetail, cell.outputDetail, cell.thinkingDetail, + cell.inputDetail, cell.outputDetail, cell.thinkingDetail, t, ], ) const resultText = useMemo( () => recordResultText(cell), [cell.result, cell.resultPreviewMarkdown], ) - const toolCallOnly = isToolCallOnly(cell) + const toolCallOnly = isToolCallOnly(cell, t) const toolCallText = toolCallTextParts(cell.kind, displayText) const listDisplayText = toolCallOnly - ? '(tool call only)' + ? t('record.toolCallOnly') : toolCallText === undefined ? displayText : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') @@ -1010,9 +1050,12 @@ function RecordListText({ displayText, toolCallOnly, toolCallText, -}: Pick) { + t, +}: Pick & { + t: TrajectoryTranslate +}) { if (toolCallOnly) { - return (tool call only) + return {t('record.toolCallOnly')} } if (toolCallText === undefined) return displayText || '—' return ( @@ -1033,15 +1076,17 @@ function MarkdownFragment({ text, rendered, preview, + t, }: { text: string rendered: boolean preview: boolean + t: TrajectoryTranslate }) { if (rendered) { return (
- +
) } @@ -1055,9 +1100,11 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, + t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void + t: TrajectoryTranslate }) { return (
@@ -1068,14 +1115,14 @@ function SourceBlocks({ @@ -1083,12 +1130,12 @@ function SourceBlocks({ : (
- {`Block #${index + 1} ${block.type}`} + {t('block.label', { index: index + 1, type: block.type })}
)} {block.imageSrc !== undefined - ? + ? :
{block.content}
} ))} @@ -1099,9 +1146,11 @@ function SourceBlocks({ function PanelImage({ block, preview = false, + t, }: { block: TrajectorySourceBlock preview?: boolean + t: TrajectoryTranslate }) { if (block.imageSrc === undefined) return null return ( @@ -1110,7 +1159,7 @@ function PanelImage({ href={block.imageSrc} target="_blank" rel="noopener noreferrer" - title="Open image" + title={t('block.openImage')} > block.imageSrc !== undefined) ?? [] if (images.length === 0) return null return (
- {images.map((block, index) => )} + {images.map((block, index) => )}
) } @@ -1141,10 +1192,12 @@ function AssistantToolCalls({ blocks, preview, onOpenCall, + t, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean onOpenCall: (callId: string) => void + t: TrajectoryTranslate }) { const calls = blocks?.filter(block => block.type === 'tool-call') ?? [] if (calls.length === 0) return null @@ -1158,7 +1211,7 @@ function AssistantToolCalls({ {thinkingExpanded && ( @@ -1395,6 +1458,7 @@ function MarkdownRecordContent({ text={record.cell.thinkingDetail} rendered={rendered} preview={preview} + t={t} /> )}
@@ -1404,6 +1468,7 @@ function MarkdownRecordContent({ text={record.cell.outputDetail} rendered={rendered} preview={preview} + t={t} />
)} @@ -1411,10 +1476,12 @@ function MarkdownRecordContent({ blocks={record.cell.sourceBlocks} preview={preview} onOpenCall={onOpenCall} + t={t} />
) @@ -1424,37 +1491,38 @@ function MarkdownRecordContent({ const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true if (!source && !hasImages && !hasToolCalls) { - const emptyLabel = isToolCallOnly(record.cell) - ? 'Tool call only' - : record.cell.text || 'No content' + const emptyLabel = isToolCallOnly(record.cell, t) + ? t('record.toolCallOnly') + : record.cell.text || t('record.noContent') return

{emptyLabel}

} if (!rendered || (!hasImages && !hasToolCalls)) { - return + return } return (
- {source && } + {source && } {record.cell.kind === 'message' && ( )} - +
) } -function RecordTiming({ record }: { record: TableRecord }) { +function RecordTiming({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) { return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined - ? + ? : (
-
Started
-
Duration
{formatElapsedSeconds(record.cell.timeSeconds)}
-
Timing source
{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}
+
{t('timing.started')}
+
{t('timing.duration')}
{formatElapsedSeconds(record.cell.timeSeconds, t)}
+
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
) } @@ -1463,23 +1531,25 @@ function RequestTiming({ assistant, anchor, request, + t, }: { assistant: TableRecord | undefined anchor: TableRecord | undefined request: TrajectoryRequestNumber | undefined + t: TrajectoryTranslate }) { - if (assistant !== undefined) return + if (assistant !== undefined) return if (request?.startedAt !== undefined) { const duration = request.completedAt === null || request.completedAt === undefined ? null : Math.max(0, (request.completedAt - request.startedAt) / 1000) return (
-
Started
-
Duration
{formatElapsedSeconds(duration)}
+
{t('timing.started')}
+
{t('timing.duration')}
{formatElapsedSeconds(duration, t)}
-
Timing source
-
{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}
+
{t('timing.source')}
+
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
) @@ -1487,10 +1557,10 @@ function RequestTiming({ return (
-
Started
- +
{t('timing.started')}
+
-
Duration
{formatElapsedSeconds(null)}
+
{t('timing.duration')}
{formatElapsedSeconds(null, t)}
) } @@ -1499,15 +1569,17 @@ function RecordPayload({ record, direction, preview = false, + t, }: { record: TableRecord direction: 'input' | 'output' preview?: boolean + t: TrajectoryTranslate }) { const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail const missing = direction === 'input' - ? 'No payload captured' - : 'No result captured' + ? t('record.noPayload') + : t('record.noResult') if (!value) return

{missing}

const error = direction === 'output' && record.cell.isError === true const payloadClass = preview ? css.jsonPreview : css.jsonPayload @@ -1521,7 +1593,8 @@ function RecordPayload({ return ( ) @@ -1537,6 +1610,7 @@ function RecordPayload({ blocks={record.cell.outputBlocks} error={error} preview={preview} + t={t} /> ) } @@ -1554,7 +1628,7 @@ function RecordPayload({ error ? css.errorPayload : undefined, ].filter((className): className is string => className !== undefined).join(' ')} > - +
) } @@ -1562,7 +1636,8 @@ function RecordPayload({ return ( ) @@ -1572,7 +1647,7 @@ function RecordPayload({ css.payload, preview ? css.payloadPreview : undefined, error ? css.errorPayload : undefined, - value === 'No output' ? css.noOutputText : undefined, + value === t('record.noOutput') ? css.noOutputText : undefined, ].filter((value): value is string => value !== undefined).join(' ')} > {value} @@ -1583,12 +1658,14 @@ function RecordPayload({ function RecordSchema({ record, preview = false, + t, }: { record: TableRecord preview?: boolean + t: TrajectoryTranslate }) { if (!record.cell.schemaDetail) { - return

Schema unavailable

+ return

{t('record.schemaUnavailable')}

} const schema = parseToolSchema(record.cell.schemaDetail) if (schema !== undefined) { @@ -1599,10 +1676,11 @@ function RecordSchema({

{schema.description}

-

Parameters

+

{t('record.parameters')}

@@ -1691,6 +1769,7 @@ function OverviewSection({ * @returns The ledger and an optional local record inspector. */ export function TrajectoryTable({ + t, requestNumbers: sessionRequestNumbers, turns, streamingCells = [], @@ -1752,20 +1831,26 @@ export function TrajectoryTable({ useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) - const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords]) + const requestGroups = useMemo(() => new Set( + (sessionRequestNumbers ?? []).map(request => requestKey(request.turn, request.group)), + ), [sessionRequestNumbers]) + const requestBoundaries = useMemo( + () => indexRequestBoundaries(allRecords, requestGroups), + [allRecords, requestGroups], + ) const requestNumbers = useMemo( - () => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries), - [allRecords, requestBoundaries, sessionRequestNumbers], + () => indexRequestNumbers(sessionRequestNumbers), + [sessionRequestNumbers], ) const records = useMemo(() => { if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes) const turnRecords = collapsedTurns.size === 0 ? allRecords - : collapseTurnRecords(allRecords, collapsedTurns) + : collapseTurnRecords(allRecords, collapsedTurns, requestGroups, t) return collapsedAssistants.size === 0 ? turnRecords : collapseAssistantRecords(turnRecords, collapsedAssistants) - }, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes]) + }, [allRecords, collapsedAssistants, collapsedTurns, requestGroups, searchMatchIndexes, t]) const projectedVirtualRows = useMemo( () => groupTrajectoryVirtualRows(records), [records], @@ -1836,8 +1921,8 @@ export function TrajectoryTable({ record.cell.requestOnly === true && position === records.length - 1, })) const requestBoundaryRuns = useMemo( - () => indexRequestBoundaryRuns(records), - [records], + () => indexRequestBoundaryRuns(records, requestGroups), + [records, requestGroups], ) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail @@ -2213,7 +2298,7 @@ export function TrajectoryTable({
)} @@ -2239,8 +2324,8 @@ export function TrajectoryTable({ className={css.historyLoadButton} disabled={olderBusy || onLoadOlder === undefined} aria-label={olderBusy - ? 'Loading earlier history…' - : 'Load earlier history'} + ? t('history.loadingEarlierAria') + : t('history.loadEarlier')} onClick={() => { const pane = tablePaneRef.current if (pane !== null) requestOlder(pane, false) @@ -2250,10 +2335,10 @@ export function TrajectoryTable({ @@ -2493,12 +2590,13 @@ export function TrajectoryTable({ displayText={displayText} toolCallOnly={toolCallOnly} toolCallText={toolCallText} + t={t} /> {resultText !== undefined && ( → - @@ -2532,17 +2630,17 @@ export function TrajectoryTable({ || (selected !== undefined && selectedState !== undefined)) && (
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index b1448b8536..148de3bc01 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -6,6 +6,7 @@ import { } from 'react' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryTranslate } from './locales.ts' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' import { deriveTrajectoryTimeline, @@ -81,15 +82,15 @@ function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail { } } -function timelineKindLabel(kind: TrajectoryCellKind): string { +function timelineKindLabel(kind: TrajectoryCellKind, t: TrajectoryTranslate): string { switch (kind) { - case 'system': return 'SYSTEM' - case 'user': return 'USER' - case 'context': return 'CONTEXT' - case 'compacted': return 'COMPACTED' - case 'message': return 'ASSISTANT' - case 'tool': return 'TOOL' - case 'subtool': return 'SUBTOOL' + case 'system': return t('kind.system') + case 'user': return t('kind.user') + case 'context': return t('kind.context') + case 'compacted': return t('kind.compacted') + case 'message': return t('kind.assistant') + case 'tool': return t('kind.tool') + case 'subtool': return t('kind.subtool') } } @@ -105,30 +106,33 @@ function formatRecordedTime(timestamp: number): string { function timelineTooltipLabel( kind: TrajectoryCellKind, detail: TimelineRecordDetail | undefined, + t: TrajectoryTranslate, ): string { - const heading = timelineKindLabel(kind) + const heading = timelineKindLabel(kind, t) if (detail === undefined) return heading const duration = detail.durationMs === undefined ? null - : `Total ${formatTimelineOffset(detail.durationMs)}` + : t('timeline.total', { duration: formatTimelineOffset(detail.durationMs, t) }) const range = detail.startedAt === undefined ? null : detail.durationMs === undefined - ? `Started ${formatRecordedTime(detail.startedAt)}` + ? t('timeline.started', { time: formatRecordedTime(detail.startedAt) }) : `${formatRecordedTime(detail.startedAt)} → ${formatRecordedTime( detail.startedAt + detail.durationMs, )}` const segments = detail.ttftMs === undefined || detail.decodingMs === undefined ? null - : `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset( - detail.decodingMs, - )}` + : t('timeline.ttftDecoding', { + ttft: formatTimelineOffset(detail.ttftMs, t), + decoding: formatTimelineOffset(detail.decodingMs, t), + }) const timing = [duration, segments].filter(value => value !== null).join(' · ') return [heading, range, timing].filter(value => value !== null && value !== '').join('\n') } /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { + t: TrajectoryTranslate turns: readonly TrajectoryTurnModel[] mode: TrajectoryTimelineMode range: TrajectoryTimeRange | null @@ -185,12 +189,12 @@ function rangeFraction( } } -function LaneLabels() { +function LaneLabels({ t }: { t: TrajectoryTranslate }) { return ( ) } @@ -199,14 +203,16 @@ function EarlierHistoryBoundary({ loading, onHover, onLoad, + t, }: { loading: boolean onHover: () => void onLoad: (() => void) | undefined + t: TrajectoryTranslate }) { return ( @@ -215,7 +221,7 @@ function EarlierHistoryBoundary({ className={css.earlierHistory} data-earlier-history data-loading={loading || undefined} - aria-label={loading ? 'Loading earlier history' : 'Load earlier history'} + aria-label={loading ? t('history.loadingEarlierAria') : t('history.loadEarlier')} aria-disabled={loading || onLoad === undefined} onClick={onLoad} onPointerEnter={(event) => { @@ -233,6 +239,7 @@ function EarlierHistoryBoundary({ /** Overview renderer with drag ranges, click-sized focus, and Escape reset. */ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ + t, turns, mode, range, @@ -380,16 +387,17 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ if (model === null) { return ( -
+
- +
- No timing data + {t('timeline.noTimingData')} {hasEarlierRecords && ( { setHover(null) }} onLoad={loadEarlier} + t={t} /> )}
@@ -575,14 +583,14 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } return ( -
+
- +
{ setHover(null) }} onLoad={loadEarlier} + t={t} /> )} {hover !== null && hover.recordIndex === null && draft === null && ( @@ -687,7 +696,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ return ( timelineTooltipLabel(span.kind, detail)} + label={() => timelineTooltipLabel(span.kind, detail, t)} side="bottom" delayMs={TIMELINE_TOOLTIP_DELAY_MS} > diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx index 6ebce17731..b073f74f05 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx' +import type { TrajectoryTranslate } from './locales.ts' import css from './TrajectoryTurn.module.css' export interface TrajectoryTurnProps { @@ -9,6 +10,8 @@ export interface TrajectoryTurnProps { turn: number /** Message / Step headers and TrajectoryCell rows. */ children?: ReactNode + /** Trajectory locale seat. */ + t: TrajectoryTranslate } /** @@ -16,10 +19,10 @@ export interface TrajectoryTurnProps { * @param props - turn index and body children. * @returns the turn section element. */ -export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) { +export function TrajectoryTurn({ turn, children, t }: TrajectoryTurnProps) { return (
- +
{children}
) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx index c37fbbd650..7ca8a2fa2e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx @@ -1,12 +1,17 @@ // TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels. import css from './TrajectoryTurnHeader.module.css' +import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' -const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const +const COLUMN_LABEL_KEYS: readonly TrajectoryKey[] = [ + 'column.input', 'column.output', 'column.think', 'column.time', +] export interface TrajectoryTurnHeaderProps { /** 1-based turn index shown as `Turn N`. */ turn: number + /** Trajectory locale seat. */ + t: TrajectoryTranslate } /** @@ -14,14 +19,14 @@ export interface TrajectoryTurnHeaderProps { * @param props.turn - turn index. * @returns the sticky header element. */ -export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) { +export function TrajectoryTurnHeader({ turn, t }: TrajectoryTurnHeaderProps) { return (
- Turn {turn} + {t('turn.label', { turn })}
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 36727078ef..ad87b1fbfe 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -201,7 +201,7 @@ export function TrajectoryView({ seq: entry.seq, turn, step, - group: `Step ${step}`, + group: t('group.step', { step }), number: index + 1, ...(request?.status === undefined ? {} : { status: request.status }), ...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }), @@ -226,7 +226,7 @@ export function TrajectoryView({ seq: request.startSeq, turn: request.turn, step: 0, - group: `Compaction ${request.startSeq}`, + group: t('group.compaction', { seq: request.startSeq }), number: index + 1, purpose: 'compaction', status: request.status, @@ -248,7 +248,7 @@ export function TrajectoryView({ return numbered }, [ - nodes, requests, + nodes, requests, t, ]) const partialTurn = partial?.turn ?? null const partialStep = partial?.step ?? null @@ -262,11 +262,11 @@ export function TrajectoryView({ runningCalls, requests, callSchemas, - }) + }, t) return { turns, lastIndex: lastCellIndex(turns) } }, [ nodes, eventLocations, partialTurn, partialStep, - runningCalls, requests, callSchemas, + runningCalls, requests, callSchemas, t, ]) const timelinePartialSignature = partialStructureSignature(partial) const timelinePartial = useMemo(() => partial === null @@ -278,15 +278,15 @@ export function TrajectoryView({ }, [partialStep, partialTurn, timelinePartialSignature]) const timelineTurns = useMemo( - () => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex), - [finalized, timelinePartial], + () => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex, t), + [finalized, timelinePartial, t], ) const timelineMode: TrajectoryTimelineMode = actualDuration ? actualTime ? 'actual' : 'duration' : actualTime ? 'time' : 'sequence' const partialSearchTurns = useMemo( - () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex), - [finalized.lastIndex, partial], + () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex, t), + [finalized.lastIndex, partial, t], ) const searchLayouts = useMemo( () => [finalized.turns, partialSearchTurns] as const, @@ -465,6 +465,7 @@ export function TrajectoryView({ t={t} />
{ const groups = bucket(turn).groups const last = groups.at(-1) - if (last?.title === 'Message') { + if (last?.title === t('group.message')) { last.laid.push(laid) return } - groups.push({ title: 'Message', laid: [laid] }) + groups.push({ title: t('group.message'), laid: [laid] }) } const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => { if (laid.length === 0) return const groups = bucket(turn).groups - const title = `Step ${step}` + const title = t('group.step', { step }) const existing = groups.find(group => group.title === title) if (existing !== undefined) { existing.laid.push(...laid) @@ -191,7 +197,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => { if (laid.length === 0) return const groups = bucket(turn).groups - const title = `Step ${step}` + const title = t('group.step', { step }) const existing = groups.find(group => group.title === title) if (existing === undefined) { groups.push({ title, laid: [...laid] }) @@ -286,7 +292,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T cell: { index: ++index, kind: 'system', - text: promptChangeLabel(change), + text: promptChangeLabel(change, t), sourceSeq: change.seq, ...(request.prompt === undefined ? {} : { promptDetail: request.prompt }), ...(change.previous === undefined @@ -309,11 +315,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T index: ++index, kind: 'compacted', text: request.status === 'running' - ? 'Compacting context…' + ? t('layout.compacting') : request.status === 'error' - ? request.error ?? 'Compaction failed' + ? request.error === COMPACTION_INTERRUPTED_ERROR + ? t('layout.compactionInterrupted') + : request.error ?? t('layout.compactionFailed') : request.summary === undefined - ? 'Context compacted' + ? t('layout.compacted') : '', ...(request.status === 'complete' && request.summary !== undefined ? previewContentProperty(request.summary) @@ -338,7 +346,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T attachUsage(cell, request.usage as UsageLike | undefined) const compaction: TurnBucket = { groups: [{ - title: `Compaction ${request.startSeq}`, + title: t('group.compaction', { seq: request.startSeq }), laid: [{ absTime: finiteTime(request.startedAt), cell, @@ -389,7 +397,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T } if (node.kind === 'assistant') { const laidList = withSubCalls( - expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById), + expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById, t), + t, ) if (node.step > 0) pushStep(node.turn, node.step, laidList) else for (const laid of laidList) pushMessage(node.turn, laid) @@ -421,7 +430,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'tool-result') { if (!emittedCallIds.has(node.callId)) { const toolName = node.call?.name - const resultPreview = summarizeResult(node) + const resultPreview = summarizeResult(node, t) const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), ...(toolName !== undefined ? { toolName } : {}), @@ -435,7 +444,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T ? summarizeCall(node.call.name, node.call.argsRaw) : resultAsText(resultPreview)), ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), - outputDetail: detailResult(node), + outputDetail: detailResult(node, t), outputBlocks: node.content.map(block => sourceBlock(block)), ...resultPreview, callId: node.callId, @@ -444,7 +453,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T startedAt: finiteTime(node.callTime), }, }] - for (const laid of expandSubCalls(node.subCalls, index)) { + for (const laid of expandSubCalls(node.subCalls, index, t)) { laidList.push(laid) index = laid.cell.index } @@ -466,8 +475,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T resultByCall, callStartById, callById, + t, { streaming: true }, - )) + ), t) if (partial.step > 0) pushStep(partial.turn, partial.step, laidList) else for (const laid of laidList) pushMessage(partial.turn, laid) const last = laidList[laidList.length - 1] @@ -492,7 +502,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T startedAt: finiteTime(call.time), }, }] - for (const laid of expandSubCalls(call.subCalls, index)) { + for (const laid of expandSubCalls(call.subCalls, index, t)) { laidList.push(laid) index = laid.cell.index } @@ -517,8 +527,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T } return [ - ...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry)), - ...standaloneCompactions.map(entry => toTurnModel(null, entry)), + ...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry, t)), + ...standaloneCompactions.map(entry => toTurnModel(null, entry, t)), ].sort((left, right) => firstCellIndex(left) - firstCellIndex(right)) } @@ -527,19 +537,21 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T * @param turns - Finalized layout derived with an empty-block partial anchor. * @param partial - Current in-flight assistant projection. * @param lastIndex - Highest cell index in the finalized layout. + * @param t - Trajectory locale translator. * @returns The original layout without a partial, otherwise a layout sharing every unaffected turn. */ export function appendTrajectoryPartialLayout( turns: readonly TrajectoryTurnModel[], partial: ConversationSnapshot['partial'], lastIndex: number, + t: TrajectoryTranslate, ): readonly TrajectoryTurnModel[] { if (partial === null) return turns const partialTurn = deriveTrajectoryLayout({ nodes: [], partial, runningCalls: [], - }).at(0) + }, t).at(0) if (partialTurn === undefined) return turns const streamed: TrajectoryTurnModel = { ...partialTurn, @@ -595,9 +607,10 @@ function attachToolSchema( function toTurnModel( turn: number | null, entry: TurnBucket, + t: TrajectoryTranslate, ): TrajectoryTurnModel { const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => { - const description = groupDescription(laid) + const description = groupDescription(laid, t) return { title, ...(description !== undefined ? { description } : {}), @@ -616,7 +629,10 @@ function firstCellIndex(turn: TrajectoryTurnModel): number { } /** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */ -function groupDescription(laid: readonly LaidCell[]): string | undefined { +function groupDescription( + laid: readonly LaidCell[], + t: TrajectoryTranslate, +): string | undefined { const parts: string[] = [] // Tool rows contribute start (absTime) and end (start + own duration) so a // single Tool cell still spans call→result for the group wall clock. @@ -629,11 +645,11 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { } } if (times.length >= 2) { - const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000) + const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000, t) if (span !== undefined) parts.push(span) } else if (times.length === 1) { const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds - const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined + const span = own !== null && own !== undefined ? formatGroupDuration(own, t) : undefined if (span !== undefined) parts.push(span) } const tools = new Map() @@ -647,9 +663,12 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { return parts.length === 0 ? undefined : parts.join(' ') } -function formatGroupDuration(seconds: number): string | undefined { +function formatGroupDuration( + seconds: number, + t: TrajectoryTranslate, +): string | undefined { if (!Number.isFinite(seconds)) return undefined - return formatElapsedSeconds(seconds) + return formatElapsedSeconds(seconds, t) } /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ @@ -670,6 +689,7 @@ function expandAssistant( results: Map, callStarts: ReadonlyMap, calls: ReadonlyMap, + t: TrajectoryTranslate, opts?: { streaming?: boolean }, ): LaidCell[] { if (opts?.streaming === true && node.blocks.length === 0) return [] @@ -697,7 +717,7 @@ function expandAssistant( sourceSeq: node.seq, text: messageText !== '' || thinkingText !== '' ? '' - : summarizeAssistantActivity(node.blocks), + : summarizeAssistantActivity(node.blocks, t), ...(messageText !== '' ? { previewMarkdown: messageText } : thinkingText !== '' @@ -729,7 +749,7 @@ function expandAssistant( : durationSeconds(result.time, result.callTime) const callAbs = finiteTime(callStarts.get(block.callId)) const call = calls.get(block.callId) - const resultPreview = result === undefined ? undefined : summarizeResult(result) + const resultPreview = result === undefined ? undefined : summarizeResult(result, t) out.push({ absTime: callAbs, toolName: block.name, @@ -742,7 +762,7 @@ function expandAssistant( callId: block.callId, ...(result !== undefined ? { - outputDetail: detailResult(result), + outputDetail: detailResult(result, t), outputBlocks: result.content.map(block => sourceBlock(block)), ...resultPreview, isError: result.isError, @@ -756,23 +776,26 @@ function expandAssistant( return out } -function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string { +function summarizeAssistantActivity( + blocks: readonly AssistantBlock[], + t: TrajectoryTranslate, +): string { const tools = new Map() for (const block of blocks) { if (block.kind !== 'tool-call') continue tools.set(block.name, (tools.get(block.name) ?? 0) + 1) } if (tools.size > 0) { - return 'Tool call only' + return t('layout.toolCallOnly') } return '' } -function promptChangeLabel(change: RequestPromptChange): string { - if (change.kind === 'initial') return 'Initial System Prompt' - if (change.kind === 'system') return 'System Prompt Updated' - if (change.kind === 'tools') return 'Tools Updated' - return 'System Prompt and Tools Updated' +function promptChangeLabel(change: RequestPromptChange, t: TrajectoryTranslate): string { + if (change.kind === 'initial') return t('layout.initialSystemPrompt') + if (change.kind === 'system') return t('layout.systemPromptUpdated') + if (change.kind === 'tools') return t('layout.toolsUpdated') + return t('layout.systemPromptAndToolsUpdated') } function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { @@ -975,13 +998,13 @@ function collectCallIds( /** Interleave each tool cell's nested child calls right after it, reindexing followers. */ -function withSubCalls(laidList: LaidCell[]): LaidCell[] { +function withSubCalls(laidList: LaidCell[], t: TrajectoryTranslate): LaidCell[] { if (!laidList.some(laid => laid.subCalls !== undefined && laid.subCalls.length > 0)) return laidList const out: LaidCell[] = [] let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0 for (const laid of laidList) { out.push({ ...laid, cell: { ...laid.cell, index: ++index } }) - for (const sub of expandSubCalls(laid.subCalls, index)) { + for (const sub of expandSubCalls(laid.subCalls, index, t)) { out.push(sub) index = sub.cell.index } @@ -993,13 +1016,14 @@ function withSubCalls(laidList: LaidCell[]): LaidCell[] { function expandSubCalls( subs: readonly ToolCallBlock[] | undefined, startIndex: number, + t: TrajectoryTranslate, ): LaidCell[] { if (subs === undefined || subs.length === 0) return [] const out: LaidCell[] = [] let index = startIndex for (const sub of subs) { const settled = 'kind' in sub - const resultPreview = settled ? summarizeResult(sub) : undefined + const resultPreview = settled ? summarizeResult(sub, t) : undefined const laid: LaidCell = { absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time), toolName: settled ? sub.call?.name ?? sub.callId : sub.name, @@ -1018,7 +1042,7 @@ function expandSubCalls( : { inputDetail: sub.argsRaw }), ...(settled ? { - outputDetail: detailResult(sub), + outputDetail: detailResult(sub, t), outputBlocks: sub.content.map(block => sourceBlock(block)), ...resultPreview, isError: sub.isError, @@ -1033,7 +1057,7 @@ function expandSubCalls( }, } out.push(laid) - for (const child of expandSubCalls(sub.subCalls, index)) { + for (const child of expandSubCalls(sub.subCalls, index, t)) { out.push(child) index = child.cell.index } @@ -1053,6 +1077,7 @@ function summarizeCall( function summarizeResult( node: ToolResultNode, + t: TrajectoryTranslate, ): Pick { if (node.isError) { return { result: node.error?.code ?? 'error' } @@ -1062,7 +1087,7 @@ function summarizeResult( return { result: '', resultPreviewMarkdown: block.text } } } - return { result: 'No output' } + return { result: t('record.noOutput') } } function resultAsText( @@ -1076,7 +1101,7 @@ function resultAsText( } } -function detailResult(node: ToolResultNode): string { +function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { if (node.isError) { return node.error === undefined ? 'error' @@ -1091,7 +1116,7 @@ function detailResult(node: ToolResultNode): string { node.content.length === 0 || node.content.every(block => block.type === 'text' && (typeof block.text !== 'string' || block.text === '')) - ) return 'No output' + ) return t('record.noOutput') return JSON.stringify(node.content, null, 2) } diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index f8adbde640..a0d91a897d 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -1,51 +1,200 @@ -/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */ +/** `trajectory` namespace dictionaries for the complete trajectory surface. */ /** Dictionary namespace owned by this plugin. */ export const NS = 'trajectory' -/** The trajectory dictionary key set (the source of truth for both locales). */ -export type TrajectoryKey = - | 'view.trajectory' - | 'toolbar.aria' - | 'toolbar.duration' - | 'toolbar.useActualDuration' - | 'toolbar.useEqualWidth' - | 'toolbar.actualTime' - | 'toolbar.turns' - | 'toolbar.expandTurns' - | 'toolbar.collapseTurns' - | 'toolbar.calls' - | 'toolbar.expandCalls' - | 'toolbar.collapseCalls' - | 'toolbar.search' - | 'toolbar.searchPlaceholder' +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'view.trajectory': '轨迹', + 'toolbar.aria': '轨迹工具栏', + 'toolbar.duration': '时长', + 'toolbar.useActualDuration': '使用实际时长', + 'toolbar.useEqualWidth': '使用等宽操作', + 'toolbar.actualTime': '实际时间', + 'toolbar.turns': '轮次', + 'toolbar.expandTurns': '展开所有轮次', + 'toolbar.collapseTurns': '收起所有轮次', + 'toolbar.calls': '调用', + 'toolbar.expandCalls': '展开所有调用', + 'toolbar.collapseCalls': '收起所有调用', + 'toolbar.search': '搜索轨迹', + 'toolbar.searchPlaceholder': '搜索', + 'kind.system': '系统', + 'kind.user': '用户', + 'kind.context': '上下文', + 'kind.compacted': '已压缩', + 'kind.message': '消息', + 'kind.assistant': '助手', + 'kind.tool': '工具', + 'kind.subtool': '子工具', + 'kind.sub': '子项', + 'column.input': '输入', + 'column.output': '输出', + 'column.think': '思考', + 'column.time': '时间', + 'column.model': '模型', + 'column.tools': '工具', + 'turn.label': '第 {turn} 轮', + 'section.betweenTurns': '轮次之间', + 'group.message': '消息', + 'group.step': '步骤 {step}', + 'group.compaction': '压缩 {seq}', + 'status.failed': '失败', + 'status.pending': '等待中', + 'status.completed': '已完成', + 'timing.notAvailable': '不可用', + 'timing.notRecorded': '未记录', + 'timing.stepStartUnavailable': '步骤开始时间不可用', + 'timing.firstTokenUnavailable': '首 token 时间不可用', + 'timing.usageUnavailable': '用量不可用', + 'timing.outputTokensUnavailable': '输出 token 数不可用', + 'timing.durationTooShort': '时长过短', + 'timing.showLocalTime': '显示本地时间', + 'timing.showUnixTimestamp': '显示 Unix 时间戳', + 'timing.started': '开始时间', + 'timing.totalDuration': '总时长', + 'timing.ttft': '首 token 延迟', + 'timing.generation': '生成', + 'timing.throughput': '吞吐量', + 'timing.duration': '时长', + 'timing.source': '计时来源', + 'timing.sessionTimestamps': '会话时间戳', + 'timing.sessionTimestampsRunning': '会话时间戳(运行中)', + 'timing.request': '请求计时', + 'unit.milliseconds': '{value} 毫秒', + 'unit.seconds': '{value} 秒', + 'unit.tokens': '{value} tok', + 'unit.tokensPerSecond': '{value} tok/s', + 'usage.tokens': 'Token', + 'usage.reasoning': '推理', + 'usage.content': '内容', + 'usage.notReported': '未报告用量', + 'usage.input': '输入', + 'usage.cached': '缓存读取', + 'usage.cacheCreated': '缓存写入', + 'usage.other': '其他', + 'usage.output': '输出', + 'usage.thisRequest': '本次请求', + 'usage.sessionCumulative': '会话累计', + 'options.notRecorded': '未记录选项', + 'options.json': '请求选项 JSON', + 'source.unknown': '未知', + 'source.user': '用户', + 'source.plugin': '插件', + 'source.pluginNamed': '插件 · {plugin}', + 'source.goal': '目标', + 'source.goalRound': '目标 · Round {round}', + 'source.notRecorded': '未记录来源', + 'source.messageJson': '消息来源 JSON', + 'tab.summary': '概述', + 'tab.rawOutput': '原始输出', + 'tab.preview': '预览', + 'tab.raw': '原始内容', + 'tab.source': '来源', + 'tab.payload': '参数', + 'tab.result': '结果', + 'tab.schema': 'Schema', + 'tab.timing': '计时', + 'tab.diff': '差异', + 'tab.systemPrompt': '系统提示词', + 'tab.tools': '工具', + 'tab.options': '选项', + 'tab.usage': '用量', + 'record.toolCallOnly': '(仅工具调用)', + 'record.noContent': '无内容', + 'record.noPayload': '未捕获参数', + 'record.noResult': '未捕获结果', + 'record.noOutput': '无输出', + 'record.schemaUnavailable': 'Schema 不可用', + 'record.parameters': '参数', + 'record.resultJson': '结果 JSON', + 'record.json': 'JSON', + 'record.parametersJson': '参数 JSON', + 'record.namedParametersJson': '{name} 参数 JSON', + 'record.payloadJson': '参数 JSON', + 'record.outputJson': '结果 JSON', + 'record.thinking': '思考', + 'record.systemPromptMissing': '本次请求没有系统提示词', + 'record.toolsMissing': '本次请求没有工具', + 'record.systemPrompt': '系统提示词', + 'record.tools': '工具', + 'block.openSummary': '打开第 {index} 个块的工具调用概述', + 'block.openSummaryTitle': '打开工具调用概述', + 'block.label': '块 #{index} {type}', + 'block.openImage': '打开图片', + 'history.loadingTrajectory': '正在加载轨迹…', + 'history.loadingEarlier': '正在加载更早的历史…', + 'history.loadingEarlierAria': '正在加载更早的历史…', + 'history.loadEarlier': '加载更早的历史', + 'history.clickToLoadEarlier': '点击加载更早的历史', + 'request.label': '请求 #{request}', + 'request.labelCompaction': '请求 #{request} · 压缩', + 'request.compaction': '压缩 · {section}', + 'request.compactionPurpose': '压缩', + 'request.retryProgress': '{retry}/{maximum}', + 'request.collapsedSummary': '已收起的{kind}概述,{summary}', + 'request.collapsedTurn': '轮次', + 'request.collapsedAssistant': '助手', + 'request.rowAria': '{request}{kind},{content}', + 'request.rowPrefix': '请求 {request},', + 'request.rowAriaCompaction': '请求 {request},压缩', + 'request.noContent': '无内容', + 'summary.toolCalls.one': '{count} 个工具调用', + 'summary.toolCalls.other': '{count} 个工具调用', + 'summary.steps.one': '{count} 个步骤', + 'summary.steps.other': '{count} 个步骤', + 'details.event': '事件详情', + 'details.resize': '调整事件详情宽度', + 'details.resizeTitle': '拖动调整大小;双击恢复默认值。', + 'details.close': '关闭详情', + 'details.status': '状态', + 'details.purpose': '用途', + 'details.provider': '提供方', + 'details.model': '模型', + 'details.toolCalls': '工具调用', + 'details.subtoolCalls': '子工具调用', + 'details.error': '错误', + 'details.retry': '重试', + 'details.scheduled': '已计划', + 'details.retryDelay': '重试延迟', + 'details.result': '结果', + 'details.compacted': '已压缩', + 'details.assistantMessage': '助手消息', + 'details.source': '来源', + 'details.hierarchy': '层级', + 'details.toolCall': '工具调用', + 'timeline.aria': '轨迹时间线', + 'timeline.overviewAria': '时间线概览;水平拖动可聚焦事件', + 'timeline.noTimingData': '无计时数据', + 'timeline.total': '总计 {duration}', + 'timeline.started': '开始于 {time}', + 'timeline.ttftDecoding': '首 token {ttft} · 解码 {decoding}', + 'layout.compacting': '正在压缩上下文…', + 'layout.compactionFailed': '上下文压缩失败', + 'layout.compacted': '上下文已压缩', + 'layout.toolCallOnly': '仅工具调用', + 'layout.initialSystemPrompt': '初始系统提示词', + 'layout.systemPromptUpdated': '系统提示词已更新', + 'layout.toolsUpdated': '工具已更新', + 'layout.systemPromptAndToolsUpdated': '系统提示词和工具已更新', + 'layout.compactionInterrupted': '上下文压缩在完成前被中断。', +} as const + +/** The trajectory dictionary key union. */ +export type TrajectoryKey = keyof typeof zh declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The trajectory view tab label and toolbar strings. */ - 'trajectory': TrajectoryKey + /** The complete trajectory ledger, timeline, inspector, and toolbar copy. */ + trajectory: TrajectoryKey } } -/** Simplified Chinese dictionary (the key-set source of truth). */ -export const zh: Record = { - 'view.trajectory': '轨迹', - 'toolbar.aria': '轨迹工具栏', - 'toolbar.duration': 'Duration', - 'toolbar.useActualDuration': 'Use actual duration', - 'toolbar.useEqualWidth': 'Use equal-width operations', - 'toolbar.actualTime': '实际时间', - 'toolbar.turns': 'Turns', - 'toolbar.expandTurns': 'Expand turns', - 'toolbar.collapseTurns': 'Collapse turns', - 'toolbar.calls': 'Calls', - 'toolbar.expandCalls': 'Expand calls', - 'toolbar.collapseCalls': 'Collapse calls', - 'toolbar.search': '搜索轨迹', - 'toolbar.searchPlaceholder': '搜索', -} +/** Namespace-bound translator threaded through trajectory presentation code. */ +export type TrajectoryTranslate = + import('@deepseek-ai/dsh-client-ui-slots').TranslateNS -/** English dictionary. */ +/** English dictionary, checked complete against the Chinese source of truth. */ export const en: Record = { 'view.trajectory': 'Trajectory', 'toolbar.aria': 'Trajectory toolbar', @@ -61,4 +210,163 @@ export const en: Record = { 'toolbar.collapseCalls': 'Collapse calls', 'toolbar.search': 'Search trajectory', 'toolbar.searchPlaceholder': 'Search', + 'kind.system': 'SYSTEM', + 'kind.user': 'USER', + 'kind.context': 'CONTEXT', + 'kind.compacted': 'COMPACTED', + 'kind.message': 'Message', + 'kind.assistant': 'ASSISTANT', + 'kind.tool': 'TOOL', + 'kind.subtool': 'SUBTOOL', + 'kind.sub': 'Sub', + 'column.input': 'Input', + 'column.output': 'Output', + 'column.think': 'Think', + 'column.time': 'Time', + 'column.model': 'Model', + 'column.tools': 'Tools', + 'turn.label': 'Turn {turn}', + 'section.betweenTurns': 'Between turns', + 'group.message': 'Message', + 'group.step': 'Step {step}', + 'group.compaction': 'Compaction {seq}', + 'status.failed': 'Failed', + 'status.pending': 'Pending', + 'status.completed': 'Completed', + 'timing.notAvailable': 'Not available', + 'timing.notRecorded': 'Not recorded', + 'timing.stepStartUnavailable': 'Step start unavailable', + 'timing.firstTokenUnavailable': 'First token unavailable', + 'timing.usageUnavailable': 'Usage unavailable', + 'timing.outputTokensUnavailable': 'Output tokens unavailable', + 'timing.durationTooShort': 'Duration too short', + 'timing.showLocalTime': 'Show local time', + 'timing.showUnixTimestamp': 'Show Unix timestamp', + 'timing.started': 'Started', + 'timing.totalDuration': 'Total duration', + 'timing.ttft': 'TTFT', + 'timing.generation': 'Generation', + 'timing.throughput': 'Throughput', + 'timing.duration': 'Duration', + 'timing.source': 'Timing source', + 'timing.sessionTimestamps': 'Session timestamps', + 'timing.sessionTimestampsRunning': 'Session timestamps (running)', + 'timing.request': 'Request Timing', + 'unit.milliseconds': '{value} ms', + 'unit.seconds': '{value} s', + 'unit.tokens': '{value} tok', + 'unit.tokensPerSecond': '{value} tok/s', + 'usage.tokens': 'Tokens', + 'usage.reasoning': 'Reasoning', + 'usage.content': 'Content', + 'usage.notReported': 'Usage not reported', + 'usage.input': 'Input', + 'usage.cached': 'Cached', + 'usage.cacheCreated': 'Cache created', + 'usage.other': 'Other', + 'usage.output': 'Output', + 'usage.thisRequest': 'This request', + 'usage.sessionCumulative': 'Session cumulative', + 'options.notRecorded': 'Options not recorded', + 'options.json': 'Request options JSON', + 'source.unknown': 'Unknown', + 'source.user': 'User', + 'source.plugin': 'Plugin', + 'source.pluginNamed': 'Plugin · {plugin}', + 'source.goal': 'Goal', + 'source.goalRound': 'Goal · Round {round}', + 'source.notRecorded': 'Source not recorded', + 'source.messageJson': 'Message source JSON', + 'tab.summary': 'Summary', + 'tab.rawOutput': 'Raw Output', + 'tab.preview': 'Preview', + 'tab.raw': 'Raw', + 'tab.source': 'Source', + 'tab.payload': 'Payload', + 'tab.result': 'Result', + 'tab.schema': 'Schema', + 'tab.timing': 'Timing', + 'tab.diff': 'Diff', + 'tab.systemPrompt': 'System Prompt', + 'tab.tools': 'Tools', + 'tab.options': 'Options', + 'tab.usage': 'Usage', + 'record.toolCallOnly': '(tool call only)', + 'record.noContent': 'No content', + 'record.noPayload': 'No payload captured', + 'record.noResult': 'No result captured', + 'record.noOutput': 'No output', + 'record.schemaUnavailable': 'Schema unavailable', + 'record.parameters': 'Parameters', + 'record.resultJson': 'Result JSON', + 'record.json': 'JSON', + 'record.parametersJson': 'parameters JSON', + 'record.namedParametersJson': '{name} parameters JSON', + 'record.payloadJson': 'Payload JSON', + 'record.outputJson': 'Result JSON', + 'record.thinking': 'Thinking', + 'record.systemPromptMissing': 'No system prompt in this request', + 'record.toolsMissing': 'No tools in this request', + 'record.systemPrompt': 'System Prompt', + 'record.tools': 'Tools', + 'block.openSummary': 'Open Block #{index} tool call summary', + 'block.openSummaryTitle': 'Open tool call summary', + 'block.label': 'Block #{index} {type}', + 'block.openImage': 'Open image', + 'history.loadingTrajectory': 'Loading trajectory…', + 'history.loadingEarlier': 'Loading earlier history…', + 'history.loadingEarlierAria': 'Loading earlier history…', + 'history.loadEarlier': 'Load earlier history', + 'history.clickToLoadEarlier': 'Click to load earlier history', + 'request.label': 'Request #{request}', + 'request.labelCompaction': 'Request #{request} · Compaction', + 'request.compaction': 'Compaction · {section}', + 'request.compactionPurpose': 'Compaction', + 'request.retryProgress': '{retry} of {maximum}', + 'request.collapsedSummary': 'Collapsed {kind} summary, {summary}', + 'request.collapsedTurn': 'turn', + 'request.collapsedAssistant': 'assistant', + 'request.rowAria': '{request}{kind}, {content}', + 'request.rowPrefix': 'Request {request}, ', + 'request.rowAriaCompaction': 'Request {request}, compaction', + 'request.noContent': 'no content', + 'summary.toolCalls.one': '{count} tool call', + 'summary.toolCalls.other': '{count} tool calls', + 'summary.steps.one': '{count} step', + 'summary.steps.other': '{count} steps', + 'details.event': 'Event details', + 'details.resize': 'Resize event details', + 'details.resizeTitle': 'Drag to resize. Double-click to reset.', + 'details.close': 'Close details', + 'details.status': 'Status', + 'details.purpose': 'Purpose', + 'details.provider': 'Provider', + 'details.model': 'Model', + 'details.toolCalls': 'Tool calls', + 'details.subtoolCalls': 'Subtool calls', + 'details.error': 'Error', + 'details.retry': 'Retry', + 'details.scheduled': 'Scheduled', + 'details.retryDelay': 'Retry delay', + 'details.result': 'Result', + 'details.compacted': 'Compacted', + 'details.assistantMessage': 'Assistant Message', + 'details.source': 'Source', + 'details.hierarchy': 'Hierarchy', + 'details.toolCall': 'Tool Call', + 'timeline.aria': 'Trajectory timeline', + 'timeline.overviewAria': 'Timeline overview; drag horizontally to focus events', + 'timeline.noTimingData': 'No timing data', + 'timeline.total': 'Total {duration}', + 'timeline.started': 'Started {time}', + 'timeline.ttftDecoding': 'TTFT {ttft} · Decoding {decoding}', + 'layout.compacting': 'Compacting context…', + 'layout.compactionFailed': 'Compaction failed', + 'layout.compacted': 'Context compacted', + 'layout.toolCallOnly': 'Tool call only', + 'layout.initialSystemPrompt': 'Initial System Prompt', + 'layout.systemPromptUpdated': 'System Prompt Updated', + 'layout.toolsUpdated': 'Tools Updated', + 'layout.systemPromptAndToolsUpdated': 'System Prompt and Tools Updated', + 'layout.compactionInterrupted': 'Compaction was interrupted before completion.', } diff --git a/packages/client/ui-trajectory/src/client/timeline.ts b/packages/client/ui-trajectory/src/client/timeline.ts index 6d3a0ef917..40cf1c7287 100644 --- a/packages/client/ui-trajectory/src/client/timeline.ts +++ b/packages/client/ui-trajectory/src/client/timeline.ts @@ -1,6 +1,7 @@ /** Operation-sequence and recorded-time projections for the trajectory overview. */ import type { TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryTranslate } from './locales.ts' import { formatDurationMillis } from './trajectory-record.ts' import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' @@ -37,10 +38,14 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange { /** * Format a timeline duration as an integer-millisecond label. * @param milliseconds - Non-negative duration in milliseconds. + * @param t - Trajectory locale translator. * @returns Millisecond label with thousands separators. */ -export function formatTimelineOffset(milliseconds: number): string { - return formatDurationMillis(milliseconds) +export function formatTimelineOffset( + milliseconds: number, + t: TrajectoryTranslate, +): string { + return formatDurationMillis(milliseconds, t) } function laneFor(kind: TrajectoryCellKind): number { diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index e4cd6e6e02..d22de55970 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryTranslate } from './locales.ts' /** Closed set of trajectory record kinds. */ export type TrajectoryCellKind = @@ -112,19 +113,29 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string { /** * Format a duration in milliseconds with thousands separators. * @param milliseconds - Duration in milliseconds, or `null` when absent. + * @param t - Trajectory locale translator. * @returns `—` when unknown, otherwise an integer-millisecond label. */ -export function formatDurationMillis(milliseconds: number | null): string { +export function formatDurationMillis( + milliseconds: number | null, + t: TrajectoryTranslate, +): string { if (milliseconds === null || !Number.isFinite(milliseconds)) return '—' const integer = String(Math.round(milliseconds)) - return `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} ms` + return t('unit.milliseconds', { + value: integer.replace(/\B(?=(\d{3})+(?!\d))/g, ','), + }) } /** * Format an elapsed duration given in seconds as a millisecond label. * @param seconds - Duration seconds, or `null` when absent. + * @param t - Trajectory locale translator. * @returns `—` when unknown, otherwise an integer-millisecond label. */ -export function formatElapsedSeconds(seconds: number | null): string { - return formatDurationMillis(seconds === null ? null : seconds * 1000) +export function formatElapsedSeconds( + seconds: number | null, + t: TrajectoryTranslate, +): string { + return formatDurationMillis(seconds === null ? null : seconds * 1000, t) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 8ca382bcc5..b40b47d8f2 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -4,6 +4,7 @@ import type { ConversationViewBuilder, ConversationViewDefinition, RequestView, ToolCallBlock, } from '@deepseek-ai/dsh-client-runtime/client' +import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' import type { TrajectoryConversationViewNode, TrajectoryRequestHeaderState, TrajectorySnapshot, @@ -106,7 +107,7 @@ function interruptCompactions( ...request, completedAt: boundary.time, status: 'error', - error: 'Compaction was interrupted before completion.', + error: COMPACTION_INTERRUPTED_ERROR, } } } diff --git a/packages/client/ui-trajectory/tests/cell.client.spec.tsx b/packages/client/ui-trajectory/tests/cell.client.spec.tsx index 2e7c0ddb45..76fce9e920 100644 --- a/packages/client/ui-trajectory/tests/cell.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/cell.client.spec.tsx @@ -5,12 +5,21 @@ */ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' +import type { ComponentProps } from 'react' import { - formatElapsedSeconds, - TrajectoryCell, + formatElapsedSeconds as formatElapsedSecondsWithLocale, + TrajectoryCell as LocalizedTrajectoryCell, type TrajectoryCellKind, } from '../src/client/TrajectoryCell.tsx' -import { formatDurationMillis } from '../src/client/trajectory-record.ts' +import { formatDurationMillis as formatDurationMillisWithLocale } from '../src/client/trajectory-record.ts' +import { t } from './locale.client.ts' + +const formatDurationMillis = (value: number | null) => formatDurationMillisWithLocale(value, t) +const formatElapsedSeconds = (value: number | null) => formatElapsedSecondsWithLocale(value, t) + +function TrajectoryCell(props: Omit, 't'>) { + return +} afterEach(cleanup) @@ -52,7 +61,7 @@ describe('TrajectoryCell', () => { />, ) expect(screen.getByText('#6')).toBeTruthy() - expect(screen.getByText('Tool')).toBeTruthy() + expect(screen.getByText('TOOL')).toBeTruthy() expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() expect(screen.getByText('5,000 ms')).toBeTruthy() }) @@ -88,8 +97,8 @@ describe('TrajectoryCell', () => { }) it.each([ - ['user', 'User'], - ['tool', 'Tool'], + ['user', 'USER'], + ['tool', 'TOOL'], ] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => { const { container } = render( , diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index ec8924505f..17fd1192f3 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -12,14 +12,26 @@ import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' import { - appendTrajectoryPartialLayout, deriveTrajectoryLayout, + appendTrajectoryPartialLayout as appendTrajectoryPartialLayoutWithLocale, + deriveTrajectoryLayout as deriveTrajectoryLayoutWithLocale, } from '../src/client/layout.ts' +import { t } from './locale.client.ts' + +const deriveTrajectoryLayout = ( + input: Parameters[0], +) => deriveTrajectoryLayoutWithLocale(input, t) + +const appendTrajectoryPartialLayout = ( + turns: Parameters[0], + partial: Parameters[1], + lastIndex: number, +) => appendTrajectoryPartialLayoutWithLocale(turns, partial, lastIndex, t) afterEach(cleanup) describe('TrajectoryTurnHeader', () => { it('renders Turn N and the four metric column labels', () => { - render() + render() expect(screen.getByText('Turn 1')).toBeTruthy() expect(screen.getByText('Input')).toBeTruthy() expect(screen.getByText('Output')).toBeTruthy() @@ -45,7 +57,7 @@ describe('TrajectoryGroupHeader', () => { describe('TrajectoryTurn', () => { it('wraps a sticky header and body children', () => { render( - + , ) diff --git a/packages/client/ui-trajectory/tests/locale.client.ts b/packages/client/ui-trajectory/tests/locale.client.ts new file mode 100644 index 0000000000..7039d319b8 --- /dev/null +++ b/packages/client/ui-trajectory/tests/locale.client.ts @@ -0,0 +1,21 @@ +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { en, zh, type TrajectoryTranslate } from '../src/client/locales.ts' + +function translator(dictionary: Record): TrajectoryTranslate { + return (key, params = {}) => { + const template = dictionary[key] ?? key + return template.replace(/\{(\w+)\}/g, (_match, name: string) => { + const value = params[name] + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : '' + }) + } +} + +/** English trajectory translator for component and pure-layout tests. */ +export const t = translator({ ...commonEn, ...en }) + +/** Chinese trajectory translator for real-view fixtures that open in Chinese. */ +export const tZh = translator({ ...commonZh, ...zh }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 467da8088a..5f4dd2b307 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -3,8 +3,43 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { ComponentProps } from 'react' +import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' +import { t } from './locale.client.ts' + +function TrajectoryTable(props: Omit, 't'>) { + const inferred: Array[number] & { firstIndex: number }> = [] + for (const turn of props.turns) { + for (const group of turn.groups) { + const step = /^Step (\d+)$/.exec(group.title)?.[1] + const compaction = /^Compaction (\d+)$/.exec(group.title)?.[1] + const firstIndex = group.cells[0]?.index ?? Number.MAX_SAFE_INTEGER + if (compaction !== undefined) { + inferred.push({ + turn: turn.turn, + step: 0, + group: group.title, + number: 0, + purpose: 'compaction', + firstIndex, + }) + } else if (step !== undefined && turn.turn !== null) { + inferred.push({ + turn: turn.turn, + step: Number(step), + group: group.title, + number: 0, + firstIndex, + }) + } + } + } + const requestNumbers = props.requestNumbers ?? inferred + .sort((left, right) => left.firstIndex - right.firstIndex) + .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 })) + return +} afterEach(() => { cleanup() diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 3df145b757..aa09426805 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -31,18 +31,24 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' -import { zh, type TrajectoryKey } from '../src/client/locales.ts' +import type { TrajectoryTranslate } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' -import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx' +import { TrajectoryTimeline as LocalizedTrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx' import { TrajectoryView, type TrajectoryViewInjected, } from '../src/client/TrajectoryView.tsx' import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' +import { t as tTrajectory, tZh } from './locale.client.ts' + +function TrajectoryTimeline( + props: Omit, 't'>, +) { + return +} const SID = 's1' as SessionId const sessionSnapshots = new WeakMap>() @@ -159,7 +165,7 @@ function emptyWorkspaces() { /** Standalone view props: the session-scope standard kit the outlet would bake. */ function standaloneProps( nodes: ConversationSnapshot['nodes'], -): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { +): ConvViewProps & { t: TrajectoryTranslate } { return { sessionId: SID, useSession: fakeSession(nodes).useSession, @@ -167,8 +173,8 @@ function standaloneProps( useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, // The locale seat the outlet would inject for the declared namespace. - t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, - } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } + t: tZh, + } as unknown as ConvViewProps & { t: TrajectoryTranslate } } /** Real-stack bench: root Context + real SlotRegistry ring + the plugin fiber. */ @@ -248,7 +254,7 @@ function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES loadOlder: trajectory.loadOlder, setActualDuration: trajectory.setActualDuration, useDuration: bindSnapshotSelector(trajectory.hooks.duration), - t: (key: TrajectoryKey) => zh[key], + t: tZh, } })() : injected @@ -369,12 +375,12 @@ describe('tab switching in ConversationRoot', () => { expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() - expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() + expect(screen.getByRole('region', { name: '轨迹时间线' })).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) + fireEvent.click(screen.getByRole('button', { name: '收起所有轮次' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) - expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开所有轮次' })) + expect(screen.getByRole('row', { name: /用户/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() expect(b.loadOlder).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) @@ -397,14 +403,14 @@ describe('tab switching in ConversationRoot', () => { mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' }) - expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy() - expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy() - expect(screen.getByText('Completed')).toBeTruthy() - expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy() + fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' }) + expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy() + expect(screen.getByText('第 1 轮 · 步骤 1')).toBeTruthy() + expect(screen.getByText('已完成')).toBeTruthy() + expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Close details' })) - expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '关闭详情' })) + expect(screen.queryByRole('complementary', { name: '事件详情' })).toBeNull() }) it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => { @@ -434,11 +440,11 @@ describe('tab switching in ConversationRoot', () => { const view = mount(b.slots, nodes) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(screen.getByText('Between turns')).toBeTruthy() + expect(screen.getByText('轮次之间')).toBeTruthy() expect(view.container.textContent).not.toContain('Turn null') - fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' })) - expect(screen.getByText('Compaction · Between turns')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '请求 #2 · 压缩' })) + expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy() expect(view.container.textContent).not.toContain('Turn null') }) @@ -486,31 +492,31 @@ describe('tab switching in ConversationRoot', () => { mount(b.slots, nodes) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' }) - const secondRequest = screen.getByRole('button', { name: 'Request #4 · Compaction' }) + const firstRequest = screen.getByRole('button', { name: '请求 #2 · 压缩' }) + const secondRequest = screen.getByRole('button', { name: '请求 #4 · 压缩' }) const firstSection = firstRequest.closest('tr')?.querySelector('span') const secondSection = secondRequest.closest('tr')?.querySelector('span') - expect(firstSection?.textContent).toBe('Between turns') - expect(secondSection?.textContent).toBe('Between turns') + expect(firstSection?.textContent).toBe('轮次之间') + expect(secondSection?.textContent).toBe('轮次之间') fireEvent.click(firstRequest) expect(firstSection?.className).toMatch(/turnLabelActive/) expect(secondSection?.className).not.toMatch(/turnLabelActive/) - expect(screen.getByText('Request #2')).toBeTruthy() - expect(screen.getByText('Compaction · Between turns')).toBeTruthy() + expect(screen.getByText('请求 #2')).toBeTruthy() + expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy() fireEvent.click(secondRequest) expect(firstSection?.className).not.toMatch(/turnLabelActive/) expect(secondSection?.className).toMatch(/turnLabelActive/) - expect(screen.getByText('Request #4')).toBeTruthy() - expect(screen.getByText('Compaction · Between turns')).toBeTruthy() + expect(screen.getByText('请求 #4')).toBeTruthy() + expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy() }) it('dragging the overview focuses overlapping records without filtering the ledger', async () => { const b = await bench() mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), @@ -519,22 +525,22 @@ describe('tab switching in ConversationRoot', () => { fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 }) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBe('outside') const tablePane = screen.getByRole('table').parentElement expect(tablePane).not.toBeNull() fireEvent.click(tablePane as HTMLElement) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBeNull() fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 }) fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 }) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBe('outside') fireEvent.contextMenu(plot) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBe('outside') }) @@ -542,7 +548,7 @@ describe('tab switching in ConversationRoot', () => { const b = await bench() const view = mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), @@ -573,7 +579,7 @@ describe('tab switching in ConversationRoot', () => { ) expect(selectedRow?.getAttribute('aria-selected')).toBe('true') expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull() - expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy() + expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy() }) it('empty window keeps the toolbar and reports no timing data', async () => { @@ -581,12 +587,12 @@ describe('tab switching in ConversationRoot', () => { mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() - expect(screen.getByText('No timing data')).toBeTruthy() + expect(screen.getByText('无计时数据')).toBeTruthy() expect(screen.getByRole('button', { - name: 'Collapse turns', + name: '收起所有轮次', }).disabled).toBe(false) expect(screen.getByRole('button', { - name: 'Collapse calls', + name: '收起所有调用', }).disabled).toBe(false) expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull() @@ -694,7 +700,7 @@ describe('timeline projection', () => { .toContain('Click to load earlier history') fireEvent.click(boundary) expect(onLoadEarlier).toHaveBeenCalledOnce() - expect(screen.getByLabelText('Loading earlier history')).toBeTruthy() + expect(screen.getByLabelText('Loading earlier history…')).toBeTruthy() view.rerender( { setActualDuration={(value) => { firstDuration.set(value) }} />, ) - const duration = screen.getByRole('button', { name: 'Use actual duration' }) + const duration = screen.getByRole('button', { name: '使用实际时长' }) expect(duration.getAttribute('aria-pressed')).toBe('false') fireEvent.click(duration) @@ -1163,7 +1169,7 @@ describe('TrajectoryView state', () => { setActualDuration={(value) => { restoredDuration.set(value) }} />, ) - expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) + expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed')) .toBe('true') }) diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx index bd683959d4..9e18e5aced 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts' import css from './PlanReviewPanel.module.css' @@ -25,6 +25,10 @@ function tooltip(description: string | undefined): { title?: string } { * @returns The plan-review takeover for this request. */ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) { + const markdownLabels = useMemo(() => ({ + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + }), [t]) // The panel waits for the host's resolved frame before leaving, so repeated // clicks must not resubmit. A failed send re-enables it and shows the error. const [busy, setBusy] = useState(false) @@ -50,7 +54,7 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) { {t('plan.header')}
- +
{error}
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b2085cc151..dfb34c6819 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -125,6 +125,10 @@ export function QuestionComposer(props: QuestionComposerProps) { function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick) { const questions = pending.questions + const markdownLabels = useMemo(() => ({ + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + }), [t]) const [index, setIndex] = useState(0) const [drafts, setDrafts] = useState(() => questions.map(() => ({ selected: [], custom: '', skipped: false, @@ -289,7 +293,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick
{question.detail !== undefined && ( -
+
)}
{(question.options ?? []).map((option, optionIndex) => { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 8689da3cc9..69ddf2e313 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -335,7 +335,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.title} - {result.workspace} + {result.workspace || t('group.ungrouped')} {result.snippet !== undefined && ( {result.snippet} )} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 23649a24f7..c07984dc57 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -12,9 +12,6 @@ import { /** Group key for Sessions outside every Workspace. */ export const UNGROUPED_KEY = '' -/** Display label for the ungrouped bucket row. */ -export const UNGROUPED_LABEL = 'Ungrouped' - /** One top-level session row in a group or the flat list. */ export interface SessionNode { id: SessionId @@ -95,10 +92,10 @@ interface Group { * Directory display label: basename of the path (both separators accepted). * Ungrouped-bucket fallback for surfaces without a workspace title. * @param cwd - directory path, or undefined for the ungrouped bucket. - * @returns basename, the raw cwd when it has no basename, or the ungrouped label. + * @returns basename, the raw cwd when it has no basename, or an empty ungrouped marker. */ export function workspaceLabel(cwd: string | undefined): string { - if (cwd === undefined || cwd === '') return UNGROUPED_LABEL + if (cwd === undefined || cwd === '') return '' const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() return base !== undefined && base !== '' ? base : cwd } @@ -127,7 +124,7 @@ function sessionVisible(session: SessionSummary, current: SessionId | undefined, * and the renderer localizes its display label. */ function sessionTitle(session: SessionSummary): string { - return session.blank ? 'New Session' : session.displayTitle + return session.blank ? '' : session.displayTitle } /** Build one group without projecting session lineage into presentation. */ @@ -203,7 +200,7 @@ function groupByWorkspace( undefined, undefined, undefined, - UNGROUPED_LABEL, + '', ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder), ungroupedOrder === undefined ? 'recency' : 'account', )) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index f2e069de43..8269639774 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime, - UNGROUPED_KEY, UNGROUPED_LABEL, + UNGROUPED_KEY, } from '../src/client/tree.ts' import { createWorkspaceViewStore } from '../src/client/stores.ts' @@ -83,7 +83,7 @@ describe('deriveGroups', () => { const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! // The stored placeholder title stays canonical; the renderer swaps in // the localized New Session label via the blank flag. - expect(blankNode.title).toBe('New Session') + expect(blankNode.title).toBe('') expect(blankNode.blank).toBe(true) expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false) expect(groups[0]!.sessionCount).toBe(2) @@ -240,7 +240,7 @@ describe('deriveFlat', () => { } const rows = deriveFlat(sessions, noArchive) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) - expect(rows.map(row => row.title)).toEqual(['New Session', 'real']) + expect(rows.map(row => row.title)).toEqual(['', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) }) @@ -429,8 +429,8 @@ describe('createWorkspaceViewStore', () => { describe('workspaceLabel', () => { it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { - expect(workspaceLabel(undefined)).toBe(UNGROUPED_LABEL) - expect(workspaceLabel('')).toBe(UNGROUPED_LABEL) + expect(workspaceLabel(undefined)).toBe('') + expect(workspaceLabel('')).toBe('') expect(workspaceLabel('/projects/demo/')).toBe('demo') expect(workspaceLabel('C:\\projects\\demo\\')).toBe('demo') expect(workspaceLabel('/')).toBe('/') diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 8585d16982..8a50148e37 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — Repository scripts -Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. +Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. Source-ownership gates use syntax-aware discovery, guard against an empty or narrowed corpus, and test every admitted/excluded form that changes their detection boundary. diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 02dc89c868..ef9c3502d1 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -90,7 +90,7 @@ describe('gate graph validation', () => { expect(ids).toEqual([ 'rescope-vendor', 'knip', 'publint', 'constraints', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', - 'optional-dependency-imports', 'client-packages', 'cordis-config', + 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'cordis-config', 'runtime-closure', 'vendored-links', ]) expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ @@ -136,6 +136,15 @@ describe('gate graph validation', () => { }, ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( + 'keeps hard-coded Client UI copy enforcement in %s', + (mode) => { + const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id)) + + expect(ids).toContain('client-ui-i18n') + }, + ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( 'keeps application entrypoint enforcement in %s', (mode) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 872041c32b..029175953d 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -275,6 +275,7 @@ function ciSharedStaticGates(): Gate[] { label: 'optional dependency imports', }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), + pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] } @@ -640,6 +641,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { label: 'optional dependency imports', }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), + pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), ] } diff --git a/scripts/verify-client-ui-i18n.spec.ts b/scripts/verify-client-ui-i18n.spec.ts new file mode 100644 index 0000000000..f31f328e6d --- /dev/null +++ b/scripts/verify-client-ui-i18n.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { findUiI18nViolations } from './verify-client-ui-i18n.ts' + +function messages(source: string): string[] { + return findUiI18nViolations('packages/client/ui-example/src/client/View.tsx', source) + .map(violation => violation.text) +} + +describe('Client UI i18n source check', () => { + it('rejects direct JSX copy and copy-bearing attributes', () => { + expect(messages(` + const View = ({ ready }: { ready: boolean }) =>
+ Hard-coded text + +
+
+ `)).toEqual(['Overview', 'Hard-coded text', 'Search now', 'Wait', 'Still working']) + }) + + it('rejects copy kept in label data and copy helper returns', () => { + expect(messages(` + const TABS = [{ id: 'summary', label: 'Summary' }] + function statusLabel(status: string): string { + if (status === 'done') return 'Complete' + return 'Still running' + } + function duration(): string { return 'Not recorded' } + function mode(): string { return 'compact' } + function Dialog({ closeLabel = 'Close dialog' }: { closeLabel?: string }) { return closeLabel } + `)).toEqual(['Summary', 'Complete', 'Still running', 'Not recorded', 'Close dialog']) + }) + + it('accepts translated copy, dynamic values, structural attributes, and language tokens', () => { + expect(messages(` + const View = ({ t, value }: { t: (key: string) => string; value: string }) => ( +
+ {t('status.complete')} + null + {value === 'pending' && {value}} + {value} +
+ ) + `)).toEqual([]) + }) + + it('does not inspect locale dictionary owners', () => { + expect(findUiI18nViolations( + 'packages/client/ui-example/src/client/locales.ts', + 'export const en = { title: "Hard-coded by design" }', + )).toEqual([]) + }) +}) diff --git a/scripts/verify-client-ui-i18n.ts b/scripts/verify-client-ui-i18n.ts new file mode 100644 index 0000000000..cd3c349fa9 --- /dev/null +++ b/scripts/verify-client-ui-i18n.ts @@ -0,0 +1,329 @@ +/** + * Reject product UI copy embedded directly in Client source. + * + * Locale dictionaries are the only source files allowed to own translated + * text. Presentation code receives copy through its typed `t` seat or through + * an already-localized prop. This check covers JSX text and copy-bearing + * attributes, plus the common data/helper forms that feed them. + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' +import ts from 'typescript' + +const root = resolve(import.meta.dirname, '..') +const MINIMUM_CLIENT_UI_SOURCES = 400 + +const COPY_ATTRIBUTES = new Set([ + 'alt', + 'aria-description', + 'aria-label', + 'aria-valuetext', + 'cancelLabel', + 'closeLabel', + 'confirmLabel', + 'copyLabel', + 'description', + 'emptyLabel', + 'label', + 'placeholder', + 'title', + 'truncatedLabel', +]) +const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Placeholder|Summary|Text|Title|Tooltip)$/ + +const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|placeholder|title|tooltip)(?:s|_.*)?$/i +const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|placeholder|title|tooltip|tabs)$/i +const IMMUTABLE_LANGUAGE_TOKENS = new Set([ + 'Function', + 'K', + 'M', + 'Symbol', + 'false', + 'function()', + 'n', + 'null', + 'true', + 'undefined', +]) +const LOCALE_KEY = /^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$/ + +/** One hard-coded product-copy occurrence. */ +export interface UiI18nViolation { + /** One-based source column. */ + column: number + /** Repository-relative source path. */ + file: string + /** One-based source line. */ + line: number + /** Why this literal is treated as product copy. */ + reason: string + /** Compact literal text for the diagnostic. */ + text: string +} + +function localeOwner(file: string): boolean { + const normalized = file.replaceAll('\\', '/') + const base = normalized.slice(normalized.lastIndexOf('/') + 1) + return base === 'locale.ts' + || base === 'locales.ts' + || normalized.includes('/locales/') +} + +function containsProductText(text: string): boolean { + const normalized = text.replace(/\s+/g, ' ').trim() + return normalized !== '' + && !IMMUTABLE_LANGUAGE_TOKENS.has(normalized) + && !LOCALE_KEY.test(normalized) + && /\p{L}/u.test(normalized) +} + +function translationCall(node: ts.CallExpression): boolean { + const callee = node.expression + return ts.isIdentifier(callee) + ? callee.text === 't' + : ts.isPropertyAccessExpression(callee) && callee.name.text === 't' +} + +function propertyName(node: ts.PropertyName | ts.BindingName): string | undefined { + return ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined +} + +function copyAttribute(name: string): boolean { + return !name.endsWith('Key') + && (COPY_ATTRIBUTES.has(name) || COPY_ATTRIBUTE_SUFFIX.test(name)) +} + +function compactText(text: string): string { + const normalized = text.replace(/\s+/g, ' ').trim() + return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}...` +} + +function looksLikeNaturalText(text: string): boolean { + const normalized = text.replace(/\s+/g, ' ').trim() + return /\s|[\u3400-\u9fff]/u.test(normalized) || /^[A-Z]/.test(normalized) +} + +/** + * Find hard-coded product copy in one Client source file. + * @param file - repository-relative path used in diagnostics. + * @param sourceText - TypeScript or TSX source. + * @returns violations in source order. + */ +export function findUiI18nViolations(file: string, sourceText: string): UiI18nViolation[] { + if (localeOwner(file)) return [] + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ) + const violations = new Map() + + const report = ( + node: ts.Node, + text: string, + reason: string, + naturalOnly = false, + ): void => { + if ( + !containsProductText(text) + || (naturalOnly && !looksLikeNaturalText(text)) + || violations.has(node.getStart(source)) + ) return + const position = source.getLineAndCharacterOfPosition(node.getStart(source)) + violations.set(node.getStart(source), { + column: position.character + 1, + file, + line: position.line + 1, + reason, + text: compactText(text), + }) + } + + const collectExpression = ( + node: ts.Expression, + reason: string, + naturalOnly = false, + ): void => { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + report(node, node.text, reason, naturalOnly) + return + } + if (ts.isTemplateExpression(node)) { + report( + node, + [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join(''), + reason, + naturalOnly, + ) + return + } + if (ts.isCallExpression(node)) { + if (translationCall(node)) return + return + } + if ( + ts.isParenthesizedExpression(node) + || ts.isAsExpression(node) + || ts.isSatisfiesExpression(node) + || ts.isNonNullExpression(node) + ) { + collectExpression(node.expression, reason, naturalOnly) + return + } + if (ts.isConditionalExpression(node)) { + collectExpression(node.whenTrue, reason, naturalOnly) + collectExpression(node.whenFalse, reason, naturalOnly) + return + } + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + collectExpression(node.right, reason, naturalOnly) + } else if ( + node.operatorToken.kind === ts.SyntaxKind.PlusToken + || node.operatorToken.kind === ts.SyntaxKind.BarBarToken + || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) { + collectExpression(node.left, reason, naturalOnly) + collectExpression(node.right, reason, naturalOnly) + } + return + } + if (ts.isArrayLiteralExpression(node)) { + for (const element of node.elements) { + if (ts.isExpression(element)) collectExpression(element, reason, naturalOnly) + } + return + } + if (ts.isObjectLiteralExpression(node)) { + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + const name = propertyName(property.name) + const propertyOwnsCopy = name !== undefined + && (COPY_NAME.test(name) || COPY_SUFFIX.test(name)) + collectExpression(property.initializer, reason, naturalOnly || !propertyOwnsCopy) + } + } + } + } + + const enclosingFunctionName = (node: ts.Node): string | undefined => { + let current = node.parent + while (!ts.isSourceFile(current)) { + if (ts.isFunctionDeclaration(current) || ts.isMethodDeclaration(current)) { + return current.name === undefined ? undefined : propertyName(current.name) + } + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + const parent = current.parent + return ts.isVariableDeclaration(parent) ? propertyName(parent.name) : undefined + } + current = current.parent + } + return undefined + } + + const hasExplicitStringReturn = (node: ts.Node): boolean => { + let current = node.parent + while (!ts.isSourceFile(current)) { + if ( + ts.isFunctionDeclaration(current) + || ts.isMethodDeclaration(current) + || ts.isArrowFunction(current) + || ts.isFunctionExpression(current) + ) return current.type?.kind === ts.SyntaxKind.StringKeyword + current = current.parent + } + return false + } + + const visit = (node: ts.Node): void => { + if (ts.isJsxText(node)) report(node, node.text, 'JSX text') + + if (ts.isJsxAttribute(node)) { + const name = node.name.getText(source) + if (copyAttribute(name) && node.initializer !== undefined) { + if (ts.isStringLiteral(node.initializer)) report(node.initializer, node.initializer.text, `${name} attribute`) + else if (ts.isJsxExpression(node.initializer) && node.initializer.expression !== undefined) { + collectExpression(node.initializer.expression, `${name} attribute`) + } + } + } + + if ( + ts.isJsxExpression(node) + && node.expression !== undefined + && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)) + ) collectExpression(node.expression, 'JSX child') + + if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) { + const name = propertyName(node.name) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.initializer, `${name} property`) + } + } + + if (ts.isVariableDeclaration(node) && node.initializer !== undefined) { + const name = propertyName(node.name) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.initializer, `${name} value`) + } + } + + if (ts.isBindingElement(node) && node.initializer !== undefined) { + const name = propertyName(node.name) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.initializer, `${name} default value`) + } + } + + if (ts.isReturnStatement(node) && node.expression !== undefined) { + const name = enclosingFunctionName(node) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.expression, `${name} return value`) + } else if (file.endsWith('.tsx') && hasExplicitStringReturn(node)) { + collectExpression(node.expression, 'string return value', true) + } + } + + ts.forEachChild(node, visit) + } + visit(source) + return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column) +} + +function sourceFiles(): string[] { + return [...new Set([ + ...globSync('packages/client/*/src/**/*.tsx', { cwd: root }), + ...globSync('packages/client/ui-*/src/**/*.{ts,tsx}', { cwd: root }), + ...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }), + ])] + .map(file => file.split(sep).join('/')) + .filter(file => !file.endsWith('.d.ts')) + .sort() +} + +function main(): void { + const files = sourceFiles() + if (files.length < MINIMUM_CLIENT_UI_SOURCES) { + throw new Error( + `verify-client-ui-i18n: discovery narrowed to ${files.length} source file(s); expected at least ${MINIMUM_CLIENT_UI_SOURCES}.`, + ) + } + const violations = files.flatMap(file => + findUiI18nViolations(file, readFileSync(resolve(root, file), 'utf8'))) + if (violations.length > 0) { + console.error(`verify-client-ui-i18n: ${violations.length} hard-coded UI string(s):`) + for (const violation of violations) { + console.error( + ` ${violation.file}:${violation.line}:${violation.column} ${violation.reason}: ${JSON.stringify(violation.text)}`, + ) + } + process.exitCode = 1 + return + } + console.log(`verify-client-ui-i18n: ${files.length} Client UI source file(s) use locale-owned copy.`) +} + +if (import.meta.filename === resolve(process.argv[1] ?? '')) main() From 4f3a47d792e82cfa33967325b7c4425212b97553 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:24:50 +0800 Subject: [PATCH 211/248] fix(terminal-bash): handle terminal protocol replies Unix PowerShell emits cursor-position requests while PSReadLine starts and redraws prompts. The subprocess PTY is only a transport, so those requests went unanswered. Startup could then accept the dsh> literal echoed from its setup source as a rendered prompt, and later sends were lost or clipped. Feed raw PTY output into a zero-scrollback @xterm/headless state machine and write generated replies through the provider-owned terminal handle. Drain replies before caller input, repeat foreground inspection when terminal activity races the sample, and retain send ownership until parser and reply work quiesce. Coalesce raw chunks behind one active parser write so large Windows output cannot create thousands of queued parse callbacks. Publish pwsh only from backend stdin_read evidence and start one timeoutMs deadline before the complete startup retry loop, so inferred-idle follow-ups cannot reset the bound. Dispose the emulator when the terminal or cleanup fails. Document the fail-loud ConstrainedLanguage path and add focused coverage for split queries, reply ordering, foreground resampling, failure containment, batching, timeout, and disposal. --- .../2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 +- .../2026-08-11-pwsh-persistent-pty.md | 9 +- .../2026-08-11-pwsh-persistent-pty.zh.md | 9 +- THIRD_PARTY_NOTICES.md | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../terminal/terminal-bash/README.i18n.yaml | 4 +- packages/terminal/terminal-bash/README.md | 6 +- packages/terminal/terminal-bash/README.zh.md | 6 +- packages/terminal/terminal-bash/package.json | 3 +- packages/terminal/terminal-bash/src/config.ts | 2 +- packages/terminal/terminal-bash/src/index.ts | 57 ++-- .../terminal/terminal-bash/src/session.ts | 163 +++++++++- .../terminal-bash/tests/config.spec.ts | 1 + .../terminal-bash/tests/index.spec.ts | 60 +++- .../terminal-bash/tests/session.spec.ts | 306 ++++++++++++++++++ pnpm-lock.yaml | 8 + 19 files changed, 590 insertions(+), 63 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index 80a1959b5a..b1391a43f8 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.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 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602 -2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f +2026-08-11-pwsh-persistent-pty.md: 4c523d3c7c45e6d86942868df92b981576e76859 +2026-08-11-pwsh-persistent-pty.zh.md: 4f87490fd60ecc37ad9390e0ce990173bbafc3b8 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 8353b3ab3c..4c523d3c7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -24,7 +24,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as ### Shell dialect in `@deepseek-ai/dsh-terminal-bash` -One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`; the bash argv and environment defaults remain unchanged). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and accepts only the backend's `stdin_read` result; a printable prompt literal in echoed setup input is not readiness. One `timeoutMs` deadline owns the complete startup retry loop, so `inferred_idle` follow-up sends cannot restart the bound. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and emits terminal-protocol replies through `SubprocessTerminalHandle`; the backend drains those writes before caller input and accepts foreground state only when protocol work stayed quiet throughout inspection, so a caller's input cannot be consumed as a cursor-position response. One parser write stays active while later raw chunks coalesce into the next batch, preventing high-volume output from creating one scheduled parse per chunk. The existing sanitizer and bounded buffers remain the output projection. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic stay shared — the marker remains a readiness signal with an unconsumed payload, and the deferred BEL event channel stays deferred. ### `@deepseek-ai/dsh-tool-pwsh-persistent` @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode. The session suite pins split cursor-position queries, response-write ordering, and parse batching without a real shell; real-pwsh suites on macOS and Windows prove persistent cwd/env, secret scrubbing, UTF-8 output, multiline and here-string commands, large-output clipping, and exit/reset. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. ## Alternatives considered @@ -46,6 +46,7 @@ The Windows test surface follows master's exemption structure: terminal-bash and - **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap. - **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around. - **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end. +- **Hand-written cursor-position replies.** Rejected: the response must reflect cursor movement, wrapping, and control sequences already emitted by the shell. Fixed coordinates amplify console redraws and can exhaust bounded output; `@xterm/headless` maintains that protocol state without replacing the line-oriented output projection. - **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text. - **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred. - **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it. @@ -62,4 +63,6 @@ The Windows test surface follows master's exemption structure: terminal-bash and **Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. -**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. +**Terminal protocol replies precede caller input.** The headless emulator retains no scrollback and contributes no model-visible text; it tracks terminal control state and emits replies through the mounted subprocess provider. This adds the maintained `@xterm/headless` runtime dependency and prevents a cursor query from consuming a later tool command. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; if marker readiness remains unavailable, startup rejects at `timeoutMs` instead of publishing a shell whose setup did not complete. A later model redefinition of the `prompt` function degrades command readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi and `@xterm/headless` add process-substrate and terminal-backend dependency review respectively. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index 95048a0241..4f87490fd6 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -24,7 +24,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言 -一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`;bash 的 argv 和环境默认值保持不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并且只接受 backend 的 `stdin_read` 结果;回显引导输入中的可打印提示符字面量不代表就绪。一条 `timeoutMs` 绝对超时计时器负责限制完整启动重试循环,因此 `inferred_idle` 后续 send 无法重新计时。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过 `SubprocessTerminalHandle` 发出终端协议响应;backend 会在调用方输入前排空这些写入,并且只接受协议工作在整次检查期间保持静止时的前台状态,因此调用方输入不会被当作光标位置响应而消费。一个 parser 写入保持活跃,随后到达的原始 chunk 会合并为下一批,从而避免高输出量为每个 chunk 分别调度解析。现有 sanitizer 与有界缓冲区仍负责输出投影。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 `PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑保持共享——标记仍是载荷不被消费的就绪信号,延后的 BEL 事件通道也继续保持延后。 ### `@deepseek-ai/dsh-tool-pwsh-persistent` @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式。session 套件无需真实 shell 即可固定拆分的光标位置查询、响应写入顺序与解析批处理;macOS 和 Windows 上的真实 pwsh 套件证明持久 cwd/env、密钥清洗、UTF-8 输出、多行与 here-string 命令、大输出裁剪及退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 ## 备选方案 @@ -46,6 +46,7 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo - **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 - **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 - **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **手写光标位置响应。** 拒绝:响应必须反映 shell 已经发出的光标移动、换行折叠和控制序列。固定坐标会放大控制台重绘并可能耗尽有界输出;`@xterm/headless` 会维护这份协议状态,但不取代逐行输出投影。 - **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 - **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 - **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 @@ -62,4 +63,6 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo **输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 -**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 +**终端协议响应先于调用方输入。** headless 模拟器不保留 scrollback,也不贡献模型可见文本;它跟踪终端控制状态,并通过已挂载的进程管理提供方发出响应。这会增加受维护的 `@xterm/headless` 运行时依赖,并避免光标查询消费后续工具命令。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;若 marker 就绪持续不可用,启动会在 `timeoutMs` 到期时拒绝,而不会发布引导未完成的 shell。模型后来重定义 `prompt` 函数会使命令就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 与 `@xterm/headless` 分别增加进程基座和终端后端的依赖评审。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 436dc11642..b82665e1c9 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -53,6 +53,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c9e8aa0367..775907aed0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 7636b1e3c7933f6d70a8e40d761b3617e746c13d -config-catalog.zh.md: 4567f09059a05d3d87f336caa55fa7db831145af +config-catalog.md: f255e38fdbc3c5831a625510110bb8a52ea280ad +config-catalog.zh.md: f1f6774d957858e452f7120baeb78ef1712c6543 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7636b1e3c7..f255e38fdb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2440,7 +2440,7 @@ export interface Config { * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`. */ handoffGraceMs?: number - /** Absolute send wait bound. */ + /** Absolute bound for one send and the complete pwsh startup sequence. */ timeoutMs?: number /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4567f09059..f1f6774d95 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2442,7 +2442,7 @@ export interface Config { * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`. */ handoffGraceMs?: number - /** Absolute send wait bound. */ + /** Absolute bound for one send and the complete pwsh startup sequence. */ timeoutMs?: number /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 1a95d7fe23..2a1df9e944 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -72,7 +72,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => { it('preserves cwd and environment across calls', async () => { - root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-')) + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-'))) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-agent'", @@ -93,7 +93,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ' idleSilenceMs: 300', ' handoffGraceMs: 300', ' scrollbackLines: 20000', - ' timeoutMs: 8000', + ' timeoutMs: 60000', ' disposeGraceMs: 500', "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", ' config:', diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index d6e544137a..4f1c384f4a 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.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 packages/terminal/terminal-bash/README.md -README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba -README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36 +README.md: 2bc6dab36c8160976928a0ccf743407d27a314f2 +README.zh.md: 828f65dd5cfe71482bf74163837df75c2ed16d6a diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 2f3f59b1ac..2bc6dab36c 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -8,7 +8,7 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal` The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and accepts startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. One `timeoutMs` deadline starts before the complete pwsh startup loop, so an `inferred_idle` follow-up send does not restart the bound. Its environment drops the bash-only markers and adds `NO_COLOR`, while the first send prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs. A zero-scrollback `@xterm/headless` instance consumes the raw PTY stream and writes terminal-protocol replies, including cursor-position reports required by Unix pwsh, through the same terminal handle. The backend drains those replies before every caller input and samples foreground state only after the protocol state stayed quiet throughout inspection. It keeps one parser write active and coalesces later raw chunks into the next batch, so high-volume output does not schedule one parser task per chunk. The line-oriented sanitizer and bounded buffers remain the only output projection. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. The controlled `PROMPT_COMMAND` re-asserts that `PS1` before every prompt, so an in-shell prompt override cannot degrade later sends to silence readiness. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. @@ -32,8 +32,8 @@ A standing-policy change appends an owner-rendered superseding runtime-context s ## Known Limitations and Deferred Work -- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. +- A headless xterm instance maintains control-sequence state only for terminal-protocol replies. Returned output remains line-oriented and normalized; full-screen alternate-buffer interaction is unsupported. - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. -- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. When that prevents marker readiness, startup rejects at `timeoutMs` instead of publishing a shell whose setup did not complete. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index f3daa0a3bc..828f65dd5c 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8:会话解码路径按 UTF-8 读取 PTY 字节,未钉住编码的控制台会以宿主代码页输出非 ASCII 内容。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并且只在后端报告 `stdin_read` 后才接受启动;回显的引导文本不能发布 shell。系统会在完整的 pwsh 启动循环之前启动一条 `timeoutMs` 绝对超时计时器,因此 `inferred_idle` 后续 send 不会重新计时。其环境去掉 bash 专属标记并加 `NO_COLOR`,同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 流,并通过同一终端句柄写回终端协议响应,包括 Unix pwsh 所需的光标位置报告。后端会在每次调用方输入前排空这些响应,并且只使用协议状态在整次检查期间保持静止后采样的前台状态。它只保留一个活跃 parser 写入,并把后来到达的原始 chunk 合并为下一批,因此高输出量不会为每个 chunk 分别调度 parser 任务。逐行 sanitizer 与有界缓冲区仍是唯一的输出投影。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。受控 `PROMPT_COMMAND` 会在每次输出提示符前重新设定该 `PS1`,因此在 shell 内覆盖提示符不会使后续 send 退化到静默就绪。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 @@ -32,8 +32,8 @@ ## 已知限制与暂缓事项 -- 输出按行规范化;不支持全屏备用缓冲区交互。 +- headless xterm 实例仅为终端协议响应维护控制序列状态。返回输出仍按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。若因此无法获得 marker 就绪,启动会在 `timeoutMs` 到期时拒绝,而不会发布引导未完成的 shell。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index cb3dccce08..9123778a22 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -43,7 +43,8 @@ }, "dependencies": { "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@xterm/headless": "^6.0.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 848fd8bf9a..9a13bd9f90 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -37,7 +37,7 @@ export interface Config { * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`. */ handoffGraceMs?: number - /** Absolute send wait bound. */ + /** Absolute bound for one send and the complete pwsh startup sequence. */ timeoutMs?: number /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 79296959ad..5ddbf8a4b7 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal' -import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal' +import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } from '@deepseek-ai/dsh-terminal' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -106,52 +106,59 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio async function startupSession( session: LocalPtySession, dialect: ShellDialect, + timeoutMs: number, signal?: AbortSignal, ): Promise { + let startupOperation: TerminalSendOperation | undefined const start = async (): Promise => { if (dialect === 'bash') { await session.initialize(signal) return } - // pwsh cannot install its prompt from the environment: write the prompt - // function through the session and wait for the first marker prompt, - // which is also the readiness contract of the bash initialize path. The - // first send also pins UTF-8 output (the shared pwsh-local preamble) - // before anything runs: the session decode path treats PTY bytes as - // UTF-8, and an un-pinned console writes its host code page for - // non-ASCII output. The banner-to-prompt gap can outlast the silence - // bound, so the wait loops over follow-up sends until the controlled - // prompt is actually visible (in the viewport or the retained scrollback - // when it landed between sends), bounded by the send deadline. + // pwsh cannot install its prompt from the environment. Write the prompt + // function through the session, pin UTF-8 output before user input, and + // accept only backend stdin_read evidence; echoed setup source containing + // the printable prompt is not readiness. Follow-up sends bridge silence + // settlements during startup, while one absolute deadline bounds them. let viewport = '' for (;;) { const first = viewport.length === 0 - const operation = session.startSend({ + startupOperation = session.startSend({ text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '', submit: first, ...signal !== undefined ? { signal } : {}, }) - const result = await operation.done + const result = await startupOperation.done if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') viewport = result.viewport - const scrollback = session.read({ offset: 0, count: 20 }).text - if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break + if (result.waitReason === 'stdin_read') break } session.motd = viewport } - if (signal === undefined) { - await start() - return + const races: Promise[] = [] + let onAbort: (() => void) | undefined + if (signal !== undefined) { + const aborted = Promise.withResolvers() + onAbort = () => { aborted.reject(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + races.push(aborted.promise) + } + let deadlineTimer: NodeJS.Timeout | undefined + if (dialect === 'pwsh') { + const deadline = Promise.withResolvers() + deadlineTimer = setTimeout(() => { + startupOperation?.cancel() + deadline.reject(new Error('PTY shell did not reach readiness before startup timeout')) + }, timeoutMs) + races.push(deadline.promise) } - const aborted = Promise.withResolvers() - const onAbort = (): void => { aborted.reject(signal.reason) } - signal.addEventListener('abort', onAbort, { once: true }) try { - signal.throwIfAborted() - await Promise.race([start(), aborted.promise]) + signal?.throwIfAborted() + await Promise.race([start(), ...races]) } finally { - signal.removeEventListener('abort', onAbort) + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer) + if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort) } } @@ -190,7 +197,7 @@ export class BashTerminalBackend implements TerminalBackend { }) const session = this.createSession(terminal, this.config) try { - await startupSession(session, this.config.shellDialect, spec.signal) + await startupSession(session, this.config.shellDialect, this.config.timeoutMs, spec.signal) return session } catch (error) { try { diff --git a/packages/terminal/terminal-bash/src/session.ts b/packages/terminal/terminal-bash/src/session.ts index de0c411a60..d40db28c10 100644 --- a/packages/terminal/terminal-bash/src/session.ts +++ b/packages/terminal/terminal-bash/src/session.ts @@ -1,6 +1,8 @@ -/** Persistent PTY session over the subprocess seam's terminal primitive. */ +/** Persistent PTY session with bounded output, readiness, and terminal-protocol replies. */ import { Buffer } from 'node:buffer' +import { createRequire } from 'node:module' +import type { IDisposable, Terminal as HeadlessTerminalType } from '@xterm/headless' import type { SubprocessOutcome, SubprocessTerminalForeground, @@ -23,6 +25,9 @@ import type { import type { ResolvedConfig } from './config.ts' import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts' +// Node exposes this package's CommonJS main as default-only, so load its named export through require. +const { Terminal: HeadlessTerminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless') + function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } { if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false } const chars = Array.from(text) @@ -157,6 +162,9 @@ export class LocalPtySession implements TerminalBackendSession { motd = '' readonly pid: number private readonly decoder = new TextDecoder() + /** Protocol state only; the sanitizer and bounded buffers own returned text. */ + private readonly emulator: HeadlessTerminalType + private readonly emulatorData: IDisposable private readonly sanitizer: TerminalSanitizer private readonly scrollback: BoundedTextBuffer private readonly outputEnded = Promise.withResolvers() @@ -164,9 +172,8 @@ export class LocalPtySession implements TerminalBackendSession { private statusValue: TerminalSessionStatus = { kind: 'running' } // TODO(pty-send-state-consolidation): Fold the per-send fields below // (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/ - // activeWrite/pollingReady/polling) into one send-lifecycle owner; the - // cancellation/readiness interplay now has enough pinned tests to carry - // that refactor safely. + // activeWrite/pollingReady/polling and terminal-protocol work) into one send-lifecycle + // owner; the cancellation/readiness interplay has enough pinned tests to carry that refactor safely. private active: LocalSendOperation | undefined private activeTimer: NodeJS.Timeout | undefined private activeDeadlineTimer: NodeJS.Timeout | undefined @@ -184,12 +191,31 @@ export class LocalPtySession implements TerminalBackendSession { private closing = false private closePromise: Promise | undefined private transportFailure: Error | undefined + private emulatorWrites = Promise.resolve() + private emulatorWriteDone: (() => void) | undefined + private emulatorBuffer = '' + private emulatorWriting = false + private responseWrites = Promise.resolve() + private pendingResponseWrites = 0 + private emulatorClosed = false constructor( private readonly terminal: SubprocessTerminalHandle, private readonly config: ResolvedConfig, ) { this.pid = terminal.pid + this.emulator = new HeadlessTerminal({ cols: config.cols, rows: config.rows, scrollback: 0 }) + this.emulatorData = this.emulator.onData((data) => { + this.pendingResponseWrites += 1 + const response = this.responseWrites.then(async () => { await this.terminal.write(data) }) + this.responseWrites = response.then( + () => { this.finishResponseWrite() }, + (error: unknown) => { + this.finishResponseWrite() + if (!this.emulatorClosed && !this.closing) this.onTransportFailure(error) + }, + ) + }) this.sanitizer = new TerminalSanitizer(config.maxReadBytes) this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines) terminal.output.on('data', this.onTerminalData) @@ -250,7 +276,9 @@ export class LocalPtySession implements TerminalBackendSession { } this.activeDeadlineTimer = setTimeout(() => { if (this.active === operation) { - this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation) + this.settleActive('timeout', this.activeWrite !== undefined + || this.interrupting === operation + || this.protocolWorkPending()) } }, this.config.timeoutMs) void this.beginSend(operation, request) @@ -260,8 +288,15 @@ export class LocalPtySession implements TerminalBackendSession { private async beginSend(operation: LocalSendOperation, request: TerminalSendRequest): Promise { let foreground: SubprocessTerminalForeground | undefined try { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() + const emulatorWrites = this.emulatorWrites + const responseWrites = this.responseWrites foreground = await this.terminal.inspectForeground() + if (this.protocolStateChanged(emulatorWrites, responseWrites)) { + foreground = await this.inspectForegroundAfterProtocol() + } } catch (error: unknown) { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() // A pre-write inspection failure while cancellation owns the slot must not // release it: interruptOnce's in-flight foreground signal could land on a // successor's foreground group. The interrupt path's post-signal tail @@ -290,7 +325,7 @@ export class LocalPtySession implements TerminalBackendSession { // Cancellation owns post-write signalling and reservation release. if (operation.cancelRequested) return if (this.active === operation && operation.settled) { - this.clearActive() + this.releaseSettledActive() return } // Closing can race the awaited provider write even though static analysis sees only local assignments. @@ -301,7 +336,7 @@ export class LocalPtySession implements TerminalBackendSession { } } catch (error: unknown) { if (this.active === operation && !this.closing) { - if (operation.settled) this.clearActive() + if (operation.settled) this.releaseSettledActive() else this.failActive(error) } } @@ -363,16 +398,20 @@ export class LocalPtySession implements TerminalBackendSession { private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => { const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk - this.onData(this.decoder.decode(bytes, { stream: true })) + const data = this.decoder.decode(bytes, { stream: true }) + this.queueEmulatorData(data) + this.onData(data) } private readonly onTerminalEnd = (): void => { this.onData(this.decoder.decode()) this.appendOutput(this.sanitizer.flush()) + this.closeEmulator() this.outputEnded.resolve() } private readonly onTerminalError = (error: Error): void => { + this.closeEmulator() this.onTransportFailure(error) this.outputEnded.resolve() } @@ -409,6 +448,7 @@ export class LocalPtySession implements TerminalBackendSession { const failure = error instanceof Error ? error : new Error(String(error)) this.transportFailure ??= failure this.statusValue = { kind: 'exited', exitCode: null, signal: null } + this.closeEmulator() this.failActive(failure) void this.terminal.terminate().catch(() => {}) } @@ -437,7 +477,13 @@ export class LocalPtySession implements TerminalBackendSession { this.settleActive('session_exit') return } - const foreground = await this.terminal.inspectForeground() + if (this.protocolWorkPending()) await this.drainTerminalProtocol() + const emulatorWrites = this.emulatorWrites + const responseWrites = this.responseWrites + let foreground = await this.terminal.inspectForeground() + if (this.protocolStateChanged(emulatorWrites, responseWrites)) { + foreground = await this.inspectForegroundAfterProtocol() + } if (this.active !== operation || this.closing || this.interrupting === operation) return const idleFor = Date.now() - this.lastOutputAt if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) { @@ -465,6 +511,7 @@ export class LocalPtySession implements TerminalBackendSession { this.settleActive('inferred_idle') } } catch (error: unknown) { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error) } finally { this.polling = false @@ -475,6 +522,101 @@ export class LocalPtySession implements TerminalBackendSession { } } + /** Wait until generated replies reach the provider before another send can publish. */ + private async drainTerminalProtocol(): Promise { + for (;;) { + const emulatorWrites = this.emulatorWrites + await emulatorWrites + const responseWrites = this.responseWrites + await responseWrites + if (emulatorWrites === this.emulatorWrites && responseWrites === this.responseWrites + && !this.protocolWorkPending()) return + } + } + + /** Sample foreground state only after protocol replies are quiet for the entire inspection. */ + private async inspectForegroundAfterProtocol(): Promise { + for (;;) { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() + const emulatorWrites = this.emulatorWrites + const responseWrites = this.responseWrites + const foreground = await this.terminal.inspectForeground() + if (!this.protocolStateChanged(emulatorWrites, responseWrites)) return foreground + } + } + + private protocolStateChanged(emulatorWrites: Promise, responseWrites: Promise): boolean { + return emulatorWrites !== this.emulatorWrites || responseWrites !== this.responseWrites + || this.protocolWorkPending() + } + + private protocolWorkPending(): boolean { + return this.emulatorWriteDone !== undefined || this.pendingResponseWrites > 0 + } + + private queueEmulatorData(data: string): void { + if (this.emulatorClosed) return + this.emulatorBuffer += data + if (this.emulatorWriteDone === undefined) { + const idle = Promise.withResolvers() + this.emulatorWrites = idle.promise + this.emulatorWriteDone = () => { idle.resolve(undefined) } + } + this.pumpEmulator() + } + + private pumpEmulator(): void { + if (this.emulatorWriting || this.emulatorClosed) return + if (this.emulatorBuffer.length === 0) { + const done = this.emulatorWriteDone + this.emulatorWriteDone = undefined + done?.() + this.releaseSettledActive() + return + } + const data = this.emulatorBuffer + this.emulatorBuffer = '' + this.emulatorWriting = true + try { + this.emulator.write(data, () => { + this.emulatorWriting = false + this.pumpEmulator() + }) + } catch (error: unknown) { + this.emulatorWriting = false + this.emulatorBuffer = '' + const done = this.emulatorWriteDone + this.emulatorWriteDone = undefined + done?.() + this.releaseSettledActive() + if (!this.closing) this.onTransportFailure(error) + } + } + + private finishResponseWrite(): void { + this.pendingResponseWrites -= 1 + this.releaseSettledActive() + } + + private releaseSettledActive(): void { + const operation = this.active + if (operation === undefined || !operation.settled || this.activeWrite !== undefined + || this.interrupting === operation || this.protocolWorkPending()) return + this.clearActive() + } + + private closeEmulator(): void { + if (this.emulatorClosed) return + this.emulatorClosed = true + this.emulatorBuffer = '' + this.emulatorWriting = false + const done = this.emulatorWriteDone + this.emulatorWriteDone = undefined + done?.() + this.emulatorData.dispose() + this.emulator.dispose() + } + private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void { const operation = this.active if (operation === undefined) return @@ -537,7 +679,7 @@ export class LocalPtySession implements TerminalBackendSession { if (this.interrupting === operation) this.interrupting = undefined } if (this.active === operation && operation.settled) { - this.clearActive() + this.releaseSettledActive() } else if (this.active === operation && !this.closing) { this.pollingReady = operation this.schedulePoll(operation, 0) @@ -549,6 +691,7 @@ export class LocalPtySession implements TerminalBackendSession { // it as session_exit below, so an in-flight send is never mis-settled as // stdin_read/inferred_idle/timeout during the grace period. this.stopPolling() + this.closeEmulator() try { await this.terminal.terminate() } catch (error: unknown) { diff --git a/packages/terminal/terminal-bash/tests/config.spec.ts b/packages/terminal/terminal-bash/tests/config.spec.ts index d7557a2d90..252a49c3d1 100644 --- a/packages/terminal/terminal-bash/tests/config.spec.ts +++ b/packages/terminal/terminal-bash/tests/config.spec.ts @@ -29,6 +29,7 @@ describe('terminal-bash config', () => { expect(() => { validateConfig(config({ handoffGraceMs: 9, pollIntervalMs: 10 })) }).toThrow('handoffGraceMs must be at least pollIntervalMs') expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow() }) + }) describe('terminal-bash dialect resolution', () => { diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 4f8c347221..5207317910 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -379,7 +379,7 @@ describe('BashTerminalBackend startup rollback', () => { expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined() }) - it('keeps waiting for the marker prompt when the first send settles on silence', async () => { + it('keeps waiting for stdin_read when the first settled output only echoes the prompt literal', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) @@ -391,8 +391,8 @@ describe('BashTerminalBackend startup rollback', () => { const second = sends.length > 1 return { done: Promise.resolve({ - viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n', - waitReason: 'inferred_idle' as const, + viewport: second ? 'dsh> ' : "function prompt { 'dsh> ' }\n", + waitReason: second ? 'stdin_read' as const : 'inferred_idle' as const, sessionStatus: { kind: 'running' as const }, truncated: false, }), readOutput: () => ({ delta: '', truncated: false }), @@ -435,6 +435,60 @@ describe('BashTerminalBackend startup rollback', () => { await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') }) + it('bounds all pwsh startup retries with one deadline', async () => { + vi.useFakeTimers() + try { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const pending = Promise.withResolvers<{ + viewport: string + waitReason: 'inferred_idle' + sessionStatus: { kind: 'running' } + truncated: boolean + }>() + let sends = 0 + let cancellations = 0 + let closes = 0 + const session = { + motd: '', + startSend: () => { + sends += 1 + return { + done: sends === 1 + ? Promise.resolve({ + viewport: 'setup echo', waitReason: 'inferred_idle' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }) + : pending.promise, + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => { cancellations += 1; return true }, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + close: () => { closes += 1; return Promise.resolve() }, + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + + const spawning = backend.spawn(spec(agent(ctx))) + await vi.advanceTimersByTimeAsync(0) + expect(sends).toBe(2) + const rejected = expect(spawning).rejects.toThrow('did not reach readiness before startup timeout') + await vi.advanceTimersByTimeAsync(100) + + await rejected + expect(cancellations).toBe(1) + expect(closes).toBe(1) + } finally { + vi.useRealTimers() + } + }) + it('forwards the spawn signal into the pwsh bootstrap sends', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index bf46317c5c..897690cbcb 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -148,6 +148,310 @@ async function initialize(session: LocalPtySession, terminal: FakeTerminal): Pro } describe('LocalPtySession readiness and output', () => { + it('answers split cursor-position queries before publishing prompt readiness', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + + let initialized = false + const pending = session.initialize().then(() => { initialized = true }) + terminal.emitData('\x1b]133;D;0\x07dsh> \x1b[') + terminal.emitData('6n') + await vi.advanceTimersByTimeAsync(20) + + expect(terminal.writes).toContain('\x1b[1;6R') + expect(initialized).toBe(false) + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(10) + await pending + expect(session.motd).toBe('dsh> ') + }) + + it('drains terminal replies before caller input and re-inspects after concurrent output', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + const secondInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + let inspections = 0 + terminal.inspectForeground = async () => { + inspections += 1 + if (inspections === 1) return await firstInspection.promise + if (inspections === 2) return await secondInspection.promise + return { processGroupId: 456, inputWaiting: false } + } + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + if (data === '\x1b[1;6R') await responseGate.promise + } + + const operation = session.startSend({ text: 'caller input', submit: true }) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + firstInspection.resolve({ processGroupId: 456, inputWaiting: true }) + await Promise.resolve() + + expect(terminal.writes).toEqual(['\x1b[1;6R']) + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(0) + expect(inspections).toBe(2) + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + secondInspection.resolve({ processGroupId: 456, inputWaiting: true }) + await vi.advanceTimersByTimeAsync(0) + expect(inspections).toBe(3) + expect(terminal.writes).toEqual(['\x1b[1;6R', '\x1b[1;6R', 'caller input\r']) + + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + + it('drains a terminal reply that is pending when caller input starts', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + if (data === '\x1b[1;6R') await responseGate.promise + } + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + + const operation = session.startSend({ text: 'caller input', submit: true }) + await Promise.resolve() + expect(terminal.writes).toEqual(['\x1b[1;6R']) + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(0) + expect(terminal.writes).toEqual(['\x1b[1;6R', 'caller input\r']) + + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + + it('resamples readiness foreground state after protocol activity during inspection', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const operation = session.startSend({ text: '', submit: false }) + await Promise.resolve() + await Promise.resolve() + const internal = session as unknown as { + stopReadinessPolling(): void + pollReadiness(operation: TerminalSendOperation): Promise + settleActive(reason: 'timeout'): void + } + internal.stopReadinessPolling() + await vi.advanceTimersByTimeAsync(20) + + const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + let inspections = 0 + terminal.inspectForeground = async () => { + inspections += 1 + return inspections === 1 + ? await firstInspection.promise + : { processGroupId: 456, inputWaiting: false } + } + const polling = internal.pollReadiness(operation) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + firstInspection.resolve({ processGroupId: 456, inputWaiting: true }) + await polling + + expect(inspections).toBe(2) + expect((operation as unknown as { settled: boolean }).settled).toBe(false) + internal.settleActive('timeout') + expect((await operation.done).waitReason).toBe('timeout') + }) + + it('retains send ownership while a failed inspection drains a terminal reply', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const operation = session.startSend({ text: '', submit: false }) + await Promise.resolve() + await Promise.resolve() + const internal = session as unknown as { + stopReadinessPolling(): void + pollReadiness(operation: TerminalSendOperation): Promise + } + internal.stopReadinessPolling() + + const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + terminal.inspectForeground = async () => await inspection.promise + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + const polling = internal.pollReadiness(operation) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + inspection.reject(new Error('inspection failed with reply pending')) + await Promise.resolve() + + expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send') + responseGate.resolve(undefined) + await polling + await expect(operation.done).rejects.toThrow('inspection failed with reply pending') + }) + + it('retains pre-write ownership when inspection fails with a terminal reply pending', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + terminal.inspectForeground = async () => await inspection.promise + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + + const operation = session.startSend({ text: 'must not execute', submit: true }) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + inspection.reject(new Error('pre-write inspection failed with reply pending')) + await Promise.resolve() + + expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send') + responseGate.resolve(undefined) + await expect(operation.done).rejects.toThrow('pre-write inspection failed with reply pending') + expect(terminal.writes).toEqual(['\x1b[1;6R']) + }) + + it('retains a timed-out send until its terminal-protocol response settles', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + + const operation = session.startSend({ text: '', submit: false }) + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(100) + expect((await operation.done).waitReason).toBe('timeout') + expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send') + + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(0) + const successor = session.startSend({ text: '', submit: false }) + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect((await successor.done).waitReason).toBe('stdin_read') + }) + + it('contains terminal emulator and protocol-response failures', async () => { + const responseTerminal = new FakeTerminal() + responseTerminal.throwWrite = true + const responseSession = new LocalPtySession(responseTerminal, config()) + const responseOperation = responseSession.startSend({ text: '', submit: false }) + responseTerminal.emitData('\x1b[6n') + await expect(responseOperation.done).rejects.toThrow('write failed') + expect(responseSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null }) + + const emulatorTerminal = new FakeTerminal() + const emulatorSession = new LocalPtySession(emulatorTerminal, config()) + const emulatorOperation = emulatorSession.startSend({ text: '', submit: false }) + const emulator = (emulatorSession as unknown as { + emulator: { write(data: string, callback?: () => void): void } + }).emulator + emulator.write = () => { throw new Error('emulator failed') } + emulatorTerminal.emitData('output') + await expect(emulatorOperation.done).rejects.toThrow('emulator failed') + expect(emulatorSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null }) + }) + + it('ignores terminal-protocol failures after closing starts and drains changing queues', async () => { + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal, config()) + const internal = session as unknown as { + closing: boolean + emulator: { write(data: string, callback?: () => void): void } + emulatorWrites: Promise + responseWrites: Promise + drainTerminalProtocol(): Promise + closeEmulator(): void + } + internal.closing = true + terminal.throwWrite = true + terminal.emitData('\x1b[6n') + await internal.emulatorWrites + await internal.responseWrites + expect(session.status()).toEqual({ kind: 'running' }) + + internal.emulator.write = () => { throw new Error('late emulator failure') } + terminal.emitData('late output') + await internal.emulatorWrites + expect(session.status()).toEqual({ kind: 'running' }) + + const first = Promise.withResolvers() + internal.emulatorWrites = first.promise + const draining = internal.drainTerminalProtocol() + internal.emulatorWrites = Promise.resolve() + first.resolve(undefined) + await draining + internal.closeEmulator() + internal.closeEmulator() + terminal.emitData('after emulator close') + expect(session.status()).toEqual({ kind: 'running' }) + }) + + it('coalesces terminal output that arrives while an emulator parse is pending', async () => { + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal, config()) + const writes: Array<{ data: string; done: () => void }> = [] + const internal = session as unknown as { + emulator: { write(data: string, callback?: () => void): void } + emulatorWrites: Promise + closeEmulator(): void + } + internal.emulator.write = (data, callback) => { + writes.push({ data, done: callback ?? (() => {}) }) + } + + terminal.emitData('first') + terminal.emitData('second') + terminal.emitData('third') + await Promise.resolve() + expect(writes.map(write => write.data)).toEqual(['first']) + + writes[0]!.done() + await Promise.resolve() + expect(writes.map(write => write.data)).toEqual(['first', 'secondthird']) + writes[1]!.done() + await internal.emulatorWrites + internal.closeEmulator() + }) + it('lets queued terminal output run before the first post-write readiness poll', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() @@ -995,6 +1299,7 @@ describe('LocalPtySession readiness and output', () => { await Promise.resolve() await Promise.resolve() inspection.resolve({ processGroupId: 456, inputWaiting: false }) + await vi.advanceTimersByTimeAsync(0) await stalePoll await vi.advanceTimersByTimeAsync(10) @@ -1190,6 +1495,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => { message: 'PTY cleanup failed (survivor)', cause: terminal.terminateError, }) + expect((session as unknown as { emulatorClosed: boolean }).emulatorClosed).toBe(true) expect(terminal.kills).toEqual([]) terminal.terminateError = undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9311ceec08..db280b365d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8644,6 +8644,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -12857,6 +12860,9 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@yarnpkg/cli-dist@4.17.1': resolution: {integrity: sha512-2tiSQuJNl/L3QwTdrq6lKWDpkcnp9MGvCT/rIldHcbu3SWfnLdmehvt3eulX1hT7FFt1Gjfq3CesF+kvhFip6g==} engines: {node: '>=18.12.0'} @@ -18167,6 +18173,8 @@ snapshots: transitivePeerDependencies: - typescript + '@xterm/headless@6.0.0': {} + '@yarnpkg/cli-dist@4.17.1': {} '@yarnpkg/parsers@3.1.0': From b6b08beb0d1d81e2a9c43db2a774a1ca5747abd8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:24:50 +0800 Subject: [PATCH 212/248] test(sandbox): derive packed workspace closure The Landlock packed-install rehearsal packed a hand-maintained list of workspace tarballs. When dsh-llm gained the dsh-util-crypto runtime dependency, the list stayed stale and npm tried to fetch the unpublished release candidate from the public registry, failing both Linux master jobs with E404 before confinement ran. Read the current pnpm workspace inventory and traverse dependencies, optionalDependencies, and required peerDependencies from the packed test roots. Verify package identities, fail loudly on unresolved workspace names, sort the closure deterministically, and leave native-family packages to the existing mode-preserving native packer. Cover runtime traversal, optional-peer exclusion, native filtering, and invalid workspace metadata. Remove the obsolete vendoring exact edit for the deleted manual list so future runtime workspace additions are included by their manifests instead of becoming post-merge CI failures. --- ...6-in-repository-landlock-release.i18n.yaml | 4 +- ...26-08-06-in-repository-landlock-release.md | 2 +- ...08-06-in-repository-landlock-release.zh.md | 2 +- .../sandbox-local/tests/packed-install.e2e.ts | 40 +++---- .../tests/packed-workspace-closure.spec.ts | 37 +++++++ .../tests/packed-workspace-closure.ts | 101 ++++++++++++++++++ scripts/rescope-vendor.ts | 18 ---- 7 files changed, 154 insertions(+), 50 deletions(-) create mode 100644 packages/sandbox/sandbox-local/tests/packed-workspace-closure.spec.ts create mode 100644 packages/sandbox/sandbox-local/tests/packed-workspace-closure.ts diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml index 56c6d31682..f6b59921ba 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.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 .agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md -2026-08-06-in-repository-landlock-release.md: 82b21cc0c30338ad11583797f011794b8dbcc90c -2026-08-06-in-repository-landlock-release.zh.md: 554967fbce454fc9a45b54d735f485006f9dee51 +2026-08-06-in-repository-landlock-release.md: 25c31c3cdcc57cbcc8bd09b82ca24898ebca8268 +2026-08-06-in-repository-landlock-release.zh.md: 71cd2b7fe342003bc458e98ee3d2e25496b535bb diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md index 82b21cc0c3..25c31c3cdc 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -22,7 +22,7 @@ The public npm boundary is three organization-owned packages with one launcher-f The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. -The sandbox packed-install rehearsal no longer permits the npm registry to supply the launcher. It packs the current checkout's entry and matching native package alongside the harness dependency closure, installs those local tarballs into an external plain-Node consumer, and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. +The sandbox packed-install rehearsal does not permit the npm registry to supply the launcher. It derives the harness closure transitively from current workspace `dependencies`, `optionalDependencies`, and required `peerDependencies`; the native family stays separate because its mode-preserving pack script supplies the entry and matching platform package. The rehearsal installs those local tarballs into an external plain-Node consumer and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md index 554967fbce..71cd2b7fe3 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -22,7 +22,7 @@ Status: implemented 主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 -沙箱打包安装演练不再允许 npm 注册表提供启动器。它会将当前 checkout 的入口包、匹配的原生包和 harness 依赖闭包一起打包,把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 +沙箱打包安装演练不允许 npm 注册表提供启动器。它会根据当前 workspace 的 `dependencies`、`optionalDependencies` 与必需 `peerDependencies` 递归推导 harness 闭包;原生包家族保持独立,因为保留文件模式的打包脚本会提供入口包和匹配平台包。演练把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 ## 曾考虑的替代方案 diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 135ebb8494..8f353ffcd2 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { packedWorkspaceClosure, readWorkspacePackages } from './packed-workspace-closure.ts' /** * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework @@ -25,31 +26,7 @@ const nativeDir = join(repoRoot, 'native/landlock-run') const sourceLauncher = join(nativeDir, 'packages', `linux-${process.arch}`, 'bin', 'landlock-run') const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${process.arch}` -/** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */ -const WORKSPACE_CLOSURE = [ - 'packages/sandbox/sandbox-local', - // sandbox-local's win32 chain rung is a runtime dependency: a packed - // consumer resolves it like any other @deepseek-ai peer (koffi arrives - // from the registry). - 'packages/sandbox/sandbox-windows-acl', - 'packages/subprocess/win32-process', - 'packages/sandbox/sandbox', - 'packages/core/session', - 'packages/core/scope', - 'packages/llm/llm', - 'packages/typert/protocol', - 'packages/attachment/attachment', - 'packages/util/brand', - 'packages/util/timeout', - 'packages/runtime-diagnostics/invariants', - // The framework and the vendored packages the closure declares outright: - // rescoped into @deepseek-ai, so the consumer installs this repository's - // copies. Schemastery is a hard dependency of three members above, not a - // peer, so npm resolves it while installing them. - 'vendor/cordis', - 'vendor/cosmokit', - 'vendor/schemastery', -] +const NATIVE_PACKAGE_PREFIX = '@deepseek-ai/node-addon-landlock-run' /** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */ const E_MACHINE = { x64: 62, arm64: 183 }[process.arch as 'x64' | 'arm64'] @@ -93,15 +70,22 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- .split('\n') .map(tarball => join(nativePackDest, tarball)) + // Derive the current runtime closure so a newly introduced workspace + // dependency cannot fall through to an unpublished registry version. + const workspaceClosure = packedWorkspaceClosure( + '@deepseek-ai/dsh-sandbox-local', + readWorkspacePackages(repoRoot), + ).filter(member => !member.name.startsWith(NATIVE_PACKAGE_PREFIX)) + // Pack each harness closure member with the exact bytes publish would upload. const tarballs: string[] = [] - for (const pkg of WORKSPACE_CLOSURE) { + for (const pkg of workspaceClosure) { const pack = spawnSync('pnpm', ['pack', '--pack-destination', packDest], { - cwd: join(repoRoot, pkg), + cwd: pkg.directory, encoding: 'utf8', timeout: 120_000, }) - expect(pack.status, `pnpm pack failed for ${pkg}:\n${pack.stdout}\n${pack.stderr}`).toBe(0) + expect(pack.status, `pnpm pack failed for ${pkg.name}:\n${pack.stdout}\n${pack.stderr}`).toBe(0) const lines = pack.stdout.trim().split('\n') tarballs.push(lines[lines.length - 1] as string) } diff --git a/packages/sandbox/sandbox-local/tests/packed-workspace-closure.spec.ts b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.spec.ts new file mode 100644 index 0000000000..103a4e4b16 --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { packedWorkspaceClosure, type WorkspacePackage } from './packed-workspace-closure.ts' + +function pkg(name: string, manifest: Record = {}): WorkspacePackage { + return { name, directory: `/workspace/${name}`, manifest } +} + +describe('packed workspace closure', () => { + it('follows install edges and required peers but excludes development and optional peers', () => { + const packages = new Map([ + ['root', pkg('root', { + dependencies: { installed: 'workspace:^' }, + optionalDependencies: { optional: 'workspace:^' }, + peerDependencies: { required: 'workspace:^', omitted: 'workspace:^' }, + peerDependenciesMeta: { omitted: { optional: true } }, + devDependencies: { development: 'workspace:^' }, + })], + ['installed', pkg('installed', { dependencies: { transitive: 'workspace:^', external: '^1.0.0' } })], + ['optional', pkg('optional')], + ['required', pkg('required')], + ['omitted', pkg('omitted')], + ['development', pkg('development')], + ['transitive', pkg('transitive')], + ]) + + expect(packedWorkspaceClosure('root', packages).map(entry => entry.name)) + .toEqual(['installed', 'optional', 'required', 'root', 'transitive']) + }) + + it('fails when a workspace dependency is absent from the inventory', () => { + const packages = new Map([ + ['root', pkg('root', { dependencies: { missing: 'workspace:^' } })], + ]) + expect(() => packedWorkspaceClosure('root', packages)) + .toThrow('packed workspace closure cannot resolve missing') + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/packed-workspace-closure.ts b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.ts new file mode 100644 index 0000000000..8f24e953f2 --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.ts @@ -0,0 +1,101 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const RUNTIME_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const + +interface WorkspaceListEntry { + name: string + path: string +} + +/** One workspace manifest available to the packed-install rehearsal. */ +export interface WorkspacePackage { + name: string + directory: string + manifest: Record +} + +function dependencyEntries( + manifest: Record, + section: (typeof RUNTIME_SECTIONS)[number], +): [string, string][] { + const value = manifest[section] + if (value === null || typeof value !== 'object' || Array.isArray(value)) return [] + return Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') +} + +function optionalPeer(manifest: Record, name: string): boolean { + const metadata = manifest.peerDependenciesMeta + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) return false + const entry = (metadata as Record)[name] + return entry !== null && typeof entry === 'object' && !Array.isArray(entry) + && (entry as Record).optional === true +} + +/** + * Read the root pnpm workspace inventory and its package manifests. + * @param repoRoot - repository root containing the pnpm workspace. + * @returns Workspace packages indexed by package name. + */ +export function readWorkspacePackages(repoRoot: string): Map { + const listed = spawnSync('pnpm', ['list', '--recursive', '--depth', '-1', '--json'], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + }) + if (listed.status !== 0) { + throw new Error(`pnpm workspace inventory failed:\n${listed.stdout}\n${listed.stderr}`) + } + const parsed: unknown = JSON.parse(listed.stdout) + if (!Array.isArray(parsed)) throw new Error('pnpm workspace inventory is not an array') + const packages = new Map() + for (const value of parsed) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('pnpm workspace inventory contains a non-object entry') + } + const { name, path } = value as Partial + if (typeof name !== 'string' || typeof path !== 'string') { + throw new Error('pnpm workspace inventory entry lacks name/path') + } + const parsedManifest: unknown = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) + if (parsedManifest === null || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) { + throw new Error(`${path}/package.json is not an object`) + } + const manifest = parsedManifest as Record + if (manifest.name !== name) throw new Error(`${path}/package.json does not declare ${name}`) + if (packages.has(name)) throw new Error(`pnpm workspace inventory repeats ${name}`) + packages.set(name, { name, directory: path, manifest }) + } + return packages +} + +/** + * Follow install dependencies and required peers inside one workspace. + * @param rootName - package whose consumer closure is required. + * @param packages - workspace packages indexed by package name. + * @returns Transitive runtime closure sorted by package directory. + */ +export function packedWorkspaceClosure( + rootName: string, + packages: ReadonlyMap, +): WorkspacePackage[] { + const closure: WorkspacePackage[] = [] + const visited = new Set() + const visit = (name: string): void => { + if (visited.has(name)) return + visited.add(name) + const current = packages.get(name) + if (current === undefined) throw new Error(`packed workspace closure cannot resolve ${name}`) + closure.push(current) + for (const section of RUNTIME_SECTIONS) { + for (const [dependency, range] of dependencyEntries(current.manifest, section)) { + if (!range.startsWith('workspace:')) continue + if (section === 'peerDependencies' && optionalPeer(current.manifest, dependency)) continue + visit(dependency) + } + } + } + visit(rootName) + return closure.sort((left, right) => left.directory.localeCompare(right.directory)) +} diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index ed264e49e2..e9821e16ab 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -430,24 +430,6 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ replace: 'parseVendoredRows(\'| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')', expect: 1, }, - { - // The framework peer is a rescoped package, so the rehearsal installs this - // repository's vendored copies; cosmokit arrives as cordis's dependency. - id: 'packed-install-vendored-peer', - file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', - find: ` 'packages/runtime-diagnostics/invariants', -]`, - replace: ` 'packages/runtime-diagnostics/invariants', - // The framework and the vendored packages the closure declares outright: - // rescoped into @deepseek-ai, so the consumer installs this repository's - // copies. Schemastery is a hard dependency of three members above, not a - // peer, so npm resolves it while installing them. - 'vendor/cordis', - 'vendor/cosmokit', - 'vendor/schemastery', -]`, - expect: 1, - }, { id: 'packed-install-registry-spec', file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', From e1a5942c9aa25f5a29bdf0054eda6007d80110a7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:14:53 +0800 Subject: [PATCH 213/248] fix(client): satisfy UI localization CI gates --- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- .../ui-chat/src/client/markdown-labels.ts | 10 ------ .../client/ui-primitives/src/DiffBlock.tsx | 15 ++++----- .../client/ui-primitives/src/FoldToggle.tsx | 33 +++++++++++++++++++ packages/client/ui-primitives/src/Modal.tsx | 31 +++++++++-------- .../client/ui-primitives/src/ReadBlock.tsx | 15 ++++----- .../ui-primitives/tests/atoms.client.spec.tsx | 13 +++++++- 9 files changed, 83 insertions(+), 44 deletions(-) create mode 100644 packages/client/ui-primitives/src/FoldToggle.tsx diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6280fbe004..b979af0f72 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 9bbbb81dc1e5b4aa65619702d380eeb126cfdea3 -module-graph.zh.md: 86298ce1ed68aa550f5b0b489ee6dccd49771638 +module-graph.md: 130e7d204e613591f2b6ab31136083cc49d3bdd0 +module-graph.zh.md: 673c3f2f9d6117d200205d0127e87711f043ed85 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9bbbb81dc1..130e7d204e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1321,6 +1321,7 @@ flowchart TD pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_theme --> pkg_settings + pkg_client_ui_layout --> pkg_client_locale pkg_client_ui_layout --> pkg_client_ui_renderer pkg_client_ui_layout --> pkg_client_ui_session pkg_client_ui_layout --> pkg_client_ui_theme @@ -1841,7 +1842,7 @@ flowchart TD | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 86298ce1ed..673c3f2f9d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1323,6 +1323,7 @@ flowchart TD pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_theme --> pkg_settings + pkg_client_ui_layout --> pkg_client_locale pkg_client_ui_layout --> pkg_client_ui_renderer pkg_client_ui_layout --> pkg_client_ui_session pkg_client_ui_layout --> pkg_client_ui_theme @@ -1843,7 +1844,7 @@ flowchart TD | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/client/ui-chat/src/client/markdown-labels.ts b/packages/client/ui-chat/src/client/markdown-labels.ts index f248d27433..6c30b99396 100644 --- a/packages/client/ui-chat/src/client/markdown-labels.ts +++ b/packages/client/ui-chat/src/client/markdown-labels.ts @@ -14,13 +14,3 @@ export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { footnotes: t('markdown.footnotes'), } } - -/** - * Format the truncation footer for a JSON Markdown block. - * @param t - Chat locale seat. - * @param total - Full serialized character count. - * @returns Localized truncation footer. - */ -export function jsonTruncatedLabel(t: ChatViewSlotProps['t'], total: number): string { - return t('markdown.truncatedCharacters', { total }) -} diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 4d97c72375..1c7925331e 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' +import { FoldToggle } from './FoldToggle.tsx' import { writeClipboard } from './clipboard.ts' import css from './DiffBlock.module.css' @@ -173,15 +174,13 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
{row.text}
))} {hidden > 0 && ( - + expanded={expanded} + hidden={hidden} + labels={labels} + onToggle={onToggle} + /> )} {tail.map((row, index) => (
{row.text}
diff --git a/packages/client/ui-primitives/src/FoldToggle.tsx b/packages/client/ui-primitives/src/FoldToggle.tsx new file mode 100644 index 0000000000..e9576aadf4 --- /dev/null +++ b/packages/client/ui-primitives/src/FoldToggle.tsx @@ -0,0 +1,33 @@ +interface FoldToggleProps { + className: string | undefined + expanded: boolean + hidden: number + labels: { + collapseAria: string + expandAria: (hidden: number) => string + collapse: string + expand: (hidden: number) => string + } + onToggle: () => void +} + +/** + * Render the shared head-tail fold control with caller-owned localized copy. + * @param props - Fold state, localized labels, and toggle callback. + * @returns The accessible expand or collapse button. + */ +export function FoldToggle({ + className, expanded, hidden, labels, onToggle, +}: FoldToggleProps) { + return ( + + ) +} diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 37f3694501..fd33a8724c 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -5,6 +5,22 @@ import clsx from 'clsx' import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' +interface ModalBaseProps { + open: boolean + onClose: () => void + title: string + description?: string + children?: ReactNode + footer?: ReactNode + className?: string + contentClassName?: string +} + +type ModalProps = ModalBaseProps & ( + | { headless: true; closeLabel?: never } + | { headless?: false; closeLabel: string } +) + /** * Render a centered, body-portaled modal over a blurred page mask. * @param props.open - whether the dialog is showing. @@ -21,18 +37,7 @@ import css from './Modal.module.css' */ export function Modal({ open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, -}: { - open: boolean - onClose: () => void - title: string - closeLabel?: string - description?: string - children?: ReactNode - footer?: ReactNode - className?: string - contentClassName?: string - headless?: boolean -}) { +}: ModalProps) { useEffect(() => { if (!open) return const onKeyDown = (e: KeyboardEvent) => { @@ -60,7 +65,7 @@ export function Modal({

{title}

-
diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index eca2582978..979af09da9 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' +import { FoldToggle } from './FoldToggle.tsx' import { writeClipboard } from './clipboard.ts' import { grammarLoadCount, @@ -132,15 +133,13 @@ export function ReadBlock({
{rows(capped ? paired.slice(0, headLines) : paired)} {hidden > 0 && ( - + expanded={expanded} + hidden={hidden} + labels={labels} + onToggle={onToggle} + /> )} {capped && rows(paired.slice(paired.length - tailLines))}
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 1f23fc33ca..1f148c26a5 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -383,7 +383,7 @@ describe('Modal', () => { it('is absent while closed; Escape and mask click call onClose', () => { const onClose = vi.fn() const { rerender } = render( - body) + body) expect(screen.queryByRole('dialog')).toBeNull() rerender( Create}> @@ -406,6 +406,17 @@ describe('Modal', () => { fireEvent.click(mask) expect(onClose).toHaveBeenCalledTimes(2) }) + + it('renders headless content without the default close chrome', () => { + render( + {}} title="Custom surface" headless> + Custom body + , + ) + expect(screen.getByRole('dialog', { name: 'Custom surface' })).toBeDefined() + expect(screen.getByText('Custom body')).toBeDefined() + expect(screen.queryByRole('button')).toBeNull() + }) }) describe('ConnectionBanner', () => { From ac4ade3aaa29dd92b337276d67cb82b25fcc27c9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:22:14 +0800 Subject: [PATCH 214/248] fix(client): address localization review findings --- .../ui-chat/src/client/chat/MessageItem.tsx | 12 +- .../client/conversation-nodes/turn-error.ts | 7 +- packages/client/ui-chat/src/client/index.ts | 2 +- packages/client/ui-chat/src/client/locale.ts | 2 + .../ui-chat/tests/chat-view.client.spec.tsx | 2 +- .../src/client/contract/records.ts | 2 + .../src/client/contract/request-inspection.ts | 2 + .../client/conversation/failure-display.ts | 24 ++- .../ui-conversation/src/client/index.ts | 3 +- .../tests/failure-display.client.spec.ts | 23 +++ .../src/client/controller.ts | 5 +- .../tests/controller.client.spec.ts | 10 +- .../client/ui-primitives/src/JsonTree.tsx | 15 +- .../src/client/tool/components/ToolRow.tsx | 17 +- .../src/client/TrajectoryTable.tsx | 145 +++++++++--------- .../src/client/TrajectoryView.tsx | 2 + .../ui-trajectory/src/client/locales.ts | 2 + .../client/trajectory-assistant-definition.ts | 16 +- .../src/client/trajectory-contract.ts | 1 + .../src/client/trajectory-snapshot-builder.ts | 11 +- .../conversation-definitions.client.spec.ts | 2 + .../tests/snapshot-builder.client.spec.ts | 5 +- .../ui-trajectory/tests/table.client.spec.tsx | 91 ++++++++++- scripts/verify-client-ui-i18n.spec.ts | 17 +- scripts/verify-client-ui-i18n.ts | 37 +++-- 25 files changed, 324 insertions(+), 131 deletions(-) create mode 100644 packages/client/ui-conversation/tests/failure-display.client.spec.ts diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 7e282a1bd7..7c30c1b7db 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -39,6 +39,14 @@ interface RetryCountdown { seconds: number } +function failureMessage( + message: string, + code: unknown, + t: ChatViewSlotProps['t'], +): string { + return code === 'AUTH' ? t('message.failure.auth') : message +} + function ModelRetryItem({ node, active, t }: { node: ModelRetryNode active: boolean @@ -98,7 +106,7 @@ function ModelRetryItem({ node, active, t }: {
{t('message.retry.failure')} - {node.failure.message} + {failureMessage(node.failure.message, node.failure.code, t)}
@@ -115,7 +123,7 @@ function TurnErrorItem({ node, t }: {
{t('message.turnError')} - {node.message} + {failureMessage(node.message, node.code, t)}
{node.code !== undefined && {node.code}}
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index cee7c5c9dc..f4b0695f0b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -3,7 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TurnErrorNode } from '../contract/snapshot.ts' -import { displayFailureMessage } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { displayFailure } from '@deepseek-ai/dsh-client-ui-conversation/client' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { @@ -32,11 +32,12 @@ function lastStep(context: ConversationNodeContext): number { function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined const failure = match.event.data.reason.error + const display = displayFailure(failure) return { seq: match.event.seq, time: match.event.time, - message: displayFailureMessage(failure), - code: failure.code, + message: display.message, + ...(display.code === undefined ? {} : { code: display.code }), } } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 9c82da7564..1816fae928 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -43,7 +43,7 @@ export type { export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts' export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts' export { - contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta, + contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta, } from '@deepseek-ai/dsh-client-ui-conversation/client' /** Public merge surface for Chat renderer payloads contributed by other plugins. */ diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 7297dd7208..9f6a18fd7b 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -66,6 +66,7 @@ export const zh = { 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', 'message.retry.delay': '重试延迟:', 'message.retry.failure': '失败原因:', + 'message.failure.auth': 'API 密钥无效', 'message.turnError': '本轮运行失败', 'message.maxTokens': '已达到输出 token 上限', 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', @@ -151,6 +152,7 @@ export const en = { 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.delay': 'Retry delay: ', 'message.retry.failure': 'Failure reason: ', + 'message.failure.auth': 'API key is invalid', 'message.turnError': 'This turn failed', 'message.maxTokens': 'Output token limit reached', 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1e72de9fc9..30f25e2277 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -625,7 +625,7 @@ describe('ChatView', () => { const view = render() const statuses = view.getAllByRole('status') expect(statuses.map(status => status.textContent)).toEqual([ - '本轮运行失败API key is invalidAUTH', + '本轮运行失败API 密钥无效AUTH', '本轮运行失败plugin exploded', ]) }) diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index b715814bcc..a92e284ca8 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -176,7 +176,9 @@ export interface TurnErrorNode { time: number turn: number step: number + /** Sanitized provider message; empty when a known code owns localized copy. */ message: string + /** Stable provider failure code, when recorded. */ code?: string } diff --git a/packages/client/ui-conversation/src/client/contract/request-inspection.ts b/packages/client/ui-conversation/src/client/contract/request-inspection.ts index 8c631fa346..773b131ae2 100644 --- a/packages/client/ui-conversation/src/client/contract/request-inspection.ts +++ b/packages/client/ui-conversation/src/client/contract/request-inspection.ts @@ -37,6 +37,8 @@ interface RequestViewBase { completedAt: number | null status: 'running' | 'complete' | 'error' error?: string + /** Stable provider code for localized presentation of known failures. */ + errorCode?: string provenance?: AssistantProvenanceView requestConfig?: AssistantRequestConfig usage?: unknown diff --git a/packages/client/ui-conversation/src/client/conversation/failure-display.ts b/packages/client/ui-conversation/src/client/conversation/failure-display.ts index 88531f0857..85fdf89896 100644 --- a/packages/client/ui-conversation/src/client/conversation/failure-display.ts +++ b/packages/client/ui-conversation/src/client/conversation/failure-display.ts @@ -1,13 +1,25 @@ +/** Display-safe failure fields retained by locale-independent projections. */ +export interface DisplayFailure { + /** Stable provider failure code used for localized known-error copy. */ + code?: string + /** Sanitized provider message; empty when the code owns the display copy. */ + message: string +} + /** - * Convert a durable failure into copy that is safe to expose in the GUI. + * Convert a durable failure into locale-independent fields safe for GUI projections. * @param failure - Failure value preserved by the session event. - * @returns Display-safe copy for client projections. + * @returns Sanitized message and optional stable provider code. */ -export function displayFailureMessage(failure: unknown): string { - if (failure === null || typeof failure !== 'object') return String(failure) +export function displayFailure(failure: unknown): DisplayFailure { + if (failure === null || typeof failure !== 'object') return { message: String(failure) } const record = failure as { code?: unknown; message?: unknown } + const code = typeof record.code === 'string' ? record.code : undefined // Provider AUTH messages may echo a masked or partially preserved credential. // Keep the raw diagnostic in the session log, but never project it into UI state. - if (record.code === 'AUTH') return 'API key is invalid' - return typeof record.message === 'string' ? record.message : JSON.stringify(failure) + if (code === 'AUTH') return { code, message: '' } + return { + ...(code === undefined ? {} : { code }), + message: typeof record.message === 'string' ? record.message : JSON.stringify(failure), + } } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 59af961a7e..33474321f1 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -48,7 +48,8 @@ export type { AssistantStepMetadata } from './conversation/assistant-timing.ts' export { assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming, } from './conversation/assistant-timing.ts' -export { displayFailureMessage } from './conversation/failure-display.ts' +export { displayFailure } from './conversation/failure-display.ts' +export type { DisplayFailure } from './conversation/failure-display.ts' export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts' export { ConversationNodeAssembler } from './conversation/assembler.ts' diff --git a/packages/client/ui-conversation/tests/failure-display.client.spec.ts b/packages/client/ui-conversation/tests/failure-display.client.spec.ts new file mode 100644 index 0000000000..0db2ae6834 --- /dev/null +++ b/packages/client/ui-conversation/tests/failure-display.client.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { displayFailure } from '../src/client/conversation/failure-display.ts' + +describe('displayFailure', () => { + it('keeps ordinary diagnostics and stable provider codes', () => { + expect(displayFailure(null)).toEqual({ message: 'null' }) + expect(displayFailure('disconnected')).toEqual({ message: 'disconnected' }) + expect(displayFailure({ code: 'RATE_LIMIT', message: 'try later' })).toEqual({ + code: 'RATE_LIMIT', + message: 'try later', + }) + expect(displayFailure({ detail: 'unknown' })).toEqual({ + message: '{"detail":"unknown"}', + }) + }) + + it('removes the provider message when AUTH owns localized display copy', () => { + expect(displayFailure({ code: 'AUTH', message: 'credential sk-secret failed' })).toEqual({ + code: 'AUTH', + message: '', + }) + }) +}) diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 29bbfede8d..4acc79db0a 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -306,7 +306,7 @@ export class MessageFeedbackController implements HostObservable { expect(controller.getSnapshot().status).not.toBe('error') }) - it('describes a non-Error list rejection with a stable message', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. + it('preserves a non-Error list rejection as a diagnostic string', async () => { const { remote } = fakeRemote({ list: () => Promise.reject('socket string') }) const controller = new MessageFeedbackController(remote, SESSION) expect(await controller.ensure()).toEqual({ ok: false, - error: { code: 'transport', message: 'message feedback list failed' }, + error: { code: 'transport', message: 'socket string' }, }) }) - it('describes a non-Error mutation rejection with a stable message', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. + it('preserves a non-Error mutation rejection as a diagnostic string', async () => { const { remote } = fakeRemote({ put: () => Promise.reject('nope') }) const controller = new MessageFeedbackController(remote, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, - error: { code: 'transport', message: 'message feedback mutation failed' }, + error: { code: 'transport', message: 'nope' }, }) }) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index b9afb33960..8399f3681e 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -397,7 +397,6 @@ export function JsonTree({ expandTopLevel = true, labels, }: JsonTreeProps) { - const copyLabels = labels const rootEntries = entriesOf(data) const firstExpandableIndex = rootEntries.findIndex(([, value]) => ( isExpandableValue(value) && entriesOf(value).length > 0 @@ -526,10 +525,10 @@ export function JsonTree({ const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' const copyTitle = copyState === 'copied' - ? copyLabels.copied + ? labels.copied : copyState === 'failed' - ? copyLabels.copyFailed - : copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue + ? labels.copyFailed + : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue return (
void copy(defaultCopyMode)} onContextMenu={(event) => { event.preventDefault() @@ -626,7 +625,7 @@ export function JsonTree({ : } )} - items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)} + items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} onSelect={(id) => { void copy(id as 'json' | 'path' | 'prettyJson' | 'value') copyMenuOpenRef.current = false diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 30859bc911..96b3213a04 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -1,4 +1,4 @@ -import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, @@ -101,6 +101,11 @@ export function ToolRow({ inspect, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) + const terminalLabels = useMemo(() => terminalBlockLabels(t), [t]) + const diffLabels = useMemo(() => diffBlockLabels(t), [t]) + const readLabels = useMemo(() => readBlockLabels(t), [t]) + const searchLabels = useMemo(() => searchBlockLabels(t), [t]) + const webLabels = useMemo(() => webBlockLabels(t), [t]) const terminalBody = terminal ?? null const diffBody = diff ?? null const readBody = read ?? null @@ -179,20 +184,20 @@ export function ToolRow({ ) : diffBody !== null - ? + ? : readBody !== null - ? + ? : searchBody !== null ? ( <> @@ -204,7 +209,7 @@ export function ToolRow({ ) : webBody !== null - ? + ? : ( <> {variant === 'code' && body !== null && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 61424bbf02..b3f5f3415e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -187,9 +187,7 @@ interface ToolCallTextParts { } interface SelectedRequest { - turn: number | null - group: string - seq?: number + identity: string } interface DetailsResizeDrag { @@ -425,14 +423,13 @@ export interface TrajectoryTableProps { /** Request-inspector fields shared by ordinary generation and compaction. */ interface TrajectoryRequestNumberBase { - /** Request anchor event sequence; absent for the currently streaming ordinary request. */ - seq?: number group: string number: number status?: 'complete' | 'running' | 'error' startedAt?: number completedAt?: number | null error?: string + errorCode?: string retry?: number maxRetries?: number retryDelayMs?: number @@ -448,11 +445,15 @@ interface TrajectoryRequestNumberBase { export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & ( | { purpose?: 'assistant' + /** Request anchor event sequence; absent for the currently streaming request. */ + seq?: number turn: number step: number } | { purpose: 'compaction' + /** Request anchor event sequence and stable compaction identity. */ + seq: number turn: number | null step: 0 } @@ -523,6 +524,12 @@ function requestKey(turn: number | null, group: string): string { return `${turn}\u0000${group}` } +function requestIdentity(request: TrajectoryRequestNumber): string { + return request.purpose === 'compaction' + ? `compaction\u0000${request.seq}` + : `assistant\u0000${request.turn}\u0000${request.step}` +} + function indexRequestBoundaries( records: readonly TableRecord[], requestGroups: ReadonlySet, @@ -647,19 +654,23 @@ function assistantToolCalls( return calls } -function summarizeAssistantTools(records: readonly TableRecord[]): string { +function summarizeAssistantTools( + records: readonly TableRecord[], + t: TrajectoryTranslate, +): string { const names = [...new Set(records.map((record) => { const separator = record.cell.text.indexOf(' · ') return separator === -1 ? record.cell.text : record.cell.text.slice(0, separator) }).filter(name => name !== ''))] const count = records.length - const summary = `${count} tool ${count === 1 ? 'call' : 'calls'}` + const summary = t(count === 1 ? 'summary.toolCalls.one' : 'summary.toolCalls.other', { count }) return names.length > 0 ? `${summary} · ${names.join(', ')}` : summary } function collapseAssistantRecords( records: readonly TableRecord[], collapsedAssistants: ReadonlySet, + t: TrajectoryTranslate, ): TableRecord[] { const out: TableRecord[] = [] for (let i = 0; i < records.length; i++) { @@ -688,7 +699,7 @@ function collapseAssistantRecords( groupStart: false, turnStart: false, turnEnd: last?.turnEnd ?? false, - collapsedSummary: summarizeAssistantTools(calls), + collapsedSummary: summarizeAssistantTools(calls, t), collapsedSummaryKind: 'assistant', }) i += calls.length @@ -712,6 +723,15 @@ function statusLabel(state: RecordState, t: TrajectoryTranslate): string { return t('status.completed') } +function requestErrorMessage( + request: Pick, + t: TrajectoryTranslate, +): string | undefined { + if (request.errorCode === 'AUTH') return t('details.failure.auth') + if (request.error === COMPACTION_INTERRUPTED_ERROR) return t('layout.compactionInterrupted') + return request.error +} + function TokenRows({ cell, t }: { cell: TrajectoryCellProps; t: TrajectoryTranslate }) { const content = cell.output !== undefined && cell.think !== undefined ? Math.max(0, cell.output - cell.think) @@ -1083,10 +1103,11 @@ function MarkdownFragment({ preview: boolean t: TrajectoryTranslate }) { + const labels = useMemo(() => markdownLabels(t), [t]) if (rendered) { return (
- +
) } @@ -1849,7 +1870,7 @@ export function TrajectoryTable({ : collapseTurnRecords(allRecords, collapsedTurns, requestGroups, t) return collapsedAssistants.size === 0 ? turnRecords - : collapseAssistantRecords(turnRecords, collapsedAssistants) + : collapseAssistantRecords(turnRecords, collapsedAssistants, t) }, [allRecords, collapsedAssistants, collapsedTurns, requestGroups, searchMatchIndexes, t]) const projectedVirtualRows = useMemo( () => groupTrajectoryVirtualRows(records), @@ -1932,28 +1953,25 @@ export function TrajectoryTable({ : undefined const promptSelected = selectedPrompt !== undefined const selectedState = selected === undefined ? undefined : stateOf(selected) - const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null + const selectedRequestInfo = selectedRequest === null + ? undefined + : sessionRequestNumbers?.find(request => + requestIdentity(request) === selectedRequest.identity) + const selectedRequestRecordTemplates = useMemo(() => selectedRequestInfo === undefined ? [] : allRecords.filter(record => - record.turn === selectedRequest.turn - && record.group === selectedRequest.group, - ), [allRecords, selectedRequest]) + record.turn === selectedRequestInfo.turn + && record.group === selectedRequestInfo.group, + ), [allRecords, selectedRequestInfo]) const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord) const selectedRequestAssistant = selectedRequestRecords.find( record => record.cell.kind === 'message', ) const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0] - const selectedRequestNumber = selectedRequest === null + const selectedRequestNumber = selectedRequestInfo?.number + const selectedRequestState: RecordState | undefined = selectedRequestInfo === undefined ? undefined - : requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group)) - const selectedRequestInfo = selectedRequest === null - ? undefined - : sessionRequestNumbers?.find(request => selectedRequest.seq === undefined - ? request.turn === selectedRequest.turn && request.group === selectedRequest.group - : request.seq === selectedRequest.seq) - const selectedRequestState: RecordState | undefined = selectedRequest === null - ? undefined - : selectedRequestInfo?.status + : selectedRequestInfo.status ?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null ? 'running' : selectedRequestAssistant === undefined @@ -1996,11 +2014,11 @@ export function TrajectoryTable({ const selectedRequestCumulativeUsage = selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage const selectedRequestOptions = selectedRequestInfo?.requestConfig - const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn - const activeSection = selectedRequest === null + const activeTurn = selectedRequestInfo === undefined ? selected?.turn : selectedRequestInfo.turn + const activeSection = selectedRequestInfo === undefined ? selected?.section : selectedRequestRecords[0]?.section - const selectedTabs = selectedRequest !== null + const selectedTabs = selectedRequestInfo !== undefined ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) : selected === undefined ? [] : detailTabs(selected) const selectedParents: ParentRecords = selected === undefined @@ -2015,15 +2033,9 @@ export function TrajectoryTable({ ? undefined : sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest) const selectedAssistantRequestTarget: SelectedRequest | undefined = - selected !== undefined && selectedAssistantRequest !== undefined - ? { - turn: selected.turn, - group: selected.group, - ...(selectedAssistantRequestInfo?.seq === undefined - ? {} - : { seq: selectedAssistantRequestInfo.seq }), - } - : undefined + selectedAssistantRequestInfo === undefined + ? undefined + : { identity: requestIdentity(selectedAssistantRequestInfo) } const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined || selectedParents.message !== undefined || selectedParents.tool !== undefined @@ -2385,9 +2397,8 @@ export function TrajectoryTable({ : t(requestInfo?.purpose === 'compaction' ? 'request.labelCompaction' : 'request.label', { request }) - const requestSelected = request !== undefined - && selectedRequest?.turn === record.turn - && selectedRequest.group === record.group + const requestSelected = requestInfo !== undefined + && selectedRequest?.identity === requestIdentity(requestInfo) const sectionActive = record.turn === null ? activeSection === record.section : activeTurn === record.turn @@ -2489,11 +2500,9 @@ export function TrajectoryTable({ style={requestBoundaryStyle} onClick={(event) => { event.stopPropagation() - selectRequest({ - turn: record.turn, - group: record.group, - ...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }), - }) + if (requestInfo !== undefined) { + selectRequest({ identity: requestIdentity(requestInfo) }) + } }} onDoubleClick={(event) => { event.stopPropagation() }} /> @@ -2625,7 +2634,7 @@ export function TrajectoryTable({
- {(selectedRequest !== null + {(selectedRequestInfo !== undefined || promptSelected || (selected !== undefined && selectedState !== undefined)) && (